How to Deploy a Remix App Without Docker
Remix — and React Router 7, which is what Remix became — produces a plain Node server when you build it with the default adapter. Not a bundle for a proprietary runtime, not a container image: a JavaScript file you start with `node`. Once you internalise that, deploying it stops being mysterious, and the Dockerfile most tutorials open with turns out to be optional.
I'm Ajay, I build PandaStack. This is the four things that actually break, in the order they break, plus what the deploy looks like on a platform that builds straight from the repo.
First: know what the build produces
Run the build and you get two directories. The client build — hashed JS, CSS, and assets — is what the browser downloads. The server build is a module your Node process imports to render routes and run loaders and actions. In a default React Router 7 or Remix v2 setup these land under `build/client` and `build/server`.
The important consequence: your start command runs a server that must be able to find both. If you deploy only `build/`, forget `package.json`, and skip `node_modules`, the server starts and immediately fails to resolve its own dependencies. The deployable unit is the repo plus its production dependencies plus the build output — not the build output alone.
# Typical React Router 7 / Remix v2 scripts
npm ci # install, lockfile-exact
npm run build # -> build/client + build/server
npm run start # -> react-router-serve ./build/server/index.js
# Sanity check locally before you deploy anything:
NODE_ENV=production PORT=3000 npm run start
curl -s localhost:3000 | head -5Second: pick the right server, once
Remix's adapter model means the same app can target a Node server, a Cloudflare Worker, or a platform's function runtime. For deploying without Docker to a general-purpose host, you want the Node target — either the bundled `react-router-serve` (or `remix-serve` on older versions), or your own small Express server if you need custom middleware.
Use the bundled server unless you have a concrete reason not to. People reach for a custom Express wrapper to add one health-check route and then own a server file forever. If you do write one, the only thing it must do is create the request handler from your server build and listen on the port the platform gives you.
Third: bind to the right port and interface
This is the single most common cause of a Remix deploy that builds cleanly and serves nothing. The platform assigns a port through the `PORT` environment variable and expects your process to listen on it, on `0.0.0.0`, not on `localhost`.
A server bound to `127.0.0.1` is reachable only from inside the machine. Your health check fails, the proxy gets connection refused, and the logs show a perfectly happy 'server started' line — which is why people spend an afternoon on it.
// server.js — only if you actually need custom middleware
import { createRequestHandler } from "@react-router/express";
import express from "express";
const build = await import("./build/server/index.js");
const app = express();
app.use(express.static("build/client", { maxAge: "1y", immutable: true }));
app.get("/healthz", (_req, res) => res.status(200).send("ok"));
app.all("*", createRequestHandler({ build }));
// Both halves matter: the platform's PORT, and 0.0.0.0 — not localhost.
const port = Number(process.env.PORT) || 3000;
app.listen(port, "0.0.0.0", () => {
console.log("listening on " + port);
});Fourth: build-time vs runtime environment variables
Remix's server code reads `process.env` at runtime, which is what you want — a secret changes, you restart, done. But anything you expose to the browser is baked into the client bundle when the build runs, whether you do it through Vite's `import.meta.env` with a `VITE_` prefix or through a loader that passes values to the client.
So: server-side secrets — database URLs, API keys, session secrets — must exist at runtime and should never be in the client bundle. Public configuration must exist at build time, and changing it means a rebuild, not a restart. Getting this backwards produces either `undefined` in production or a secret shipped to every visitor, and the second one is much worse.
- Session secret, database URL, third-party API keys: runtime only. Set them on the host, read them in loaders and actions.
- Public API base URL, analytics key, feature flags visible to the client: build time. They must be present when the build runs.
- Never put a server secret behind a public prefix. The prefix is the instruction to inline it into JavaScript that anyone can read.
Deploying it, without a Dockerfile
On any platform that detects a Node repo, the whole deploy is three commands it already knows how to run: install, build, start. Here's the shape on PandaStack, where the app is built inside its own Firecracker microVM and run as a normal long-lived Node process:
pandastack app create \
--name storefront \
--git-url https://github.com/acme/storefront \
--git-branch main \
--install-command "npm ci" \
--build-command "npm run build" \
--start-command "npm run start" \
--port 3000 \
--env "SESSION_SECRET=...,DATABASE_URL=..."
# Watch the build; the deploy only goes live after a health check passes
pandastack app deploy <app-id> --followTwo details worth knowing on any host, not just this one. Node version should come from the repo — a `.nvmrc` or an `engines` field — so production matches local instead of drifting with a dashboard setting. And check that deploys are blue-green: the new version should have to pass a health check before traffic moves, with the old one still running until it does.
When it doesn't work
- Build succeeds, nothing responds → port or interface binding. Check `0.0.0.0` and `process.env.PORT` before anything else.
- Build killed with no useful error → memory. Remix builds with Vite need real RAM; a build box smaller than your laptop is the usual cause.
- `Cannot find module` on start → you deployed the build output without production dependencies, or your start command points at the wrong path. Check what your build actually wrote to `build/`.
- Works locally, 500s in production → an env var that exists in your `.env` and was never set on the host. Log the names (never the values) of what the process can see at boot.
- Assets 404 → your server isn't serving `build/client`, or a CDN is in front expecting a different path prefix.
- Hydration mismatch errors → not a hosting problem. Something rendered differently on server and client, usually a date, a random value, or a browser-only API touched during render.
When you should use a Dockerfile after all
Three honest cases. You need a system dependency the platform's build image doesn't have — a specific image-processing library, a headless browser, a font package. You need byte-identical artifacts across several unrelated environments. Or your organisation already standardises on images and swimming upstream costs more than the Dockerfile does. Outside those, a container around a Node process is a layer you maintain for no return.
Frequently asked questions
Do I need Docker to deploy a Remix app?
No. Remix and React Router 7 build to a standard Node server, so any platform that can install dependencies, run a build script, and start a Node process can host it — which is most of them, usually with no configuration beyond the port. A Dockerfile earns its place when you need a system library the platform's build image does not provide, when you need identical artifacts across several unrelated environments, or when your organisation already standardises on images. For a conventional Remix app talking to a database and an API, it is an extra layer to maintain with no benefit.
Why does my Remix app build successfully but return nothing?
Nearly always the port or the network interface. Hosting platforms assign a port through the `PORT` environment variable and expect the process to listen on `0.0.0.0`; a server bound to `localhost` or `127.0.0.1` is reachable only from inside the machine, so the platform's proxy gets connection refused and the health check fails — while your logs cheerfully report that the server started. Fix both halves: read `process.env.PORT` rather than hardcoding, and pass `0.0.0.0` as the host. If that is already correct, check that your start command points at the file your build actually produced, since the path differs between Remix versions.
Where do environment variables go in a Remix deployment?
Server-side values — database URLs, session secrets, third-party API keys — are read from `process.env` at runtime inside loaders and actions, so they only need to exist on the host when the app runs, and changing one requires a restart rather than a rebuild. Anything the browser needs is different: it is inlined into the client bundle during the build, either through Vite's public-prefixed variables or by passing values from a loader, so it must be present when the build runs and changing it requires a full rebuild. The dangerous mistake is putting a server secret behind a public prefix, which instructs the bundler to ship it to every visitor.
Should I use remix-serve or write my own Express server?
Use the bundled server unless you have a specific requirement it cannot meet. `react-router-serve` — `remix-serve` on older versions — handles static assets, the request handler, and the port correctly, which is most of what a custom server would do anyway. Write your own when you genuinely need custom middleware in front of the app: a legacy proxy route, unusual header handling, an existing Express ecosystem you must integrate with, or WebSocket handling alongside the app. The cost is that you now own a server file that must be kept in step with framework upgrades, and that file is where most Remix deployment bugs live.
How much memory does a Remix build need?
More than most small build tiers provide. A Vite-based Remix build on a real application routinely wants a couple of gigabytes or more, and the symptom of running short is a build that is killed with no useful message, or an explicit JavaScript heap out of memory. Check the build-time memory your platform allocates — it is often lower than the runtime instance size, which surprises people — and compare it against what your own machine actually uses during a build. Raising Node's heap limit helps only if the machine has the memory to give; if it does not, you need a larger builder.
Keep reading
- App hosting on PandaStack — Node apps built from the repo, no Dockerfile, blue-green deploys
- The best Node.js hosting platforms in 2026
- How to deploy a Nuxt app without Docker
- Build-time vs runtime environment variables
- Fixing JavaScript heap out of memory in a build
- Deploying a Next.js app with a git push
49ms p50 cold start. Fork, snapshot, and scale to zero.