Remix is a server-rendered React framework, which means deploying it is closer to deploying an Express app than a static site. There's a real Node server, a build step that produces two separate bundles, and — in most real apps — a database that needs migrations run in the right order. None of it is hard, but there are a few Remix-specific details that trip people up the first time. Here's the whole thing, end to end.
One note before we start: Remix v2 is in maintenance mode and the framework's ideas live on in React Router 7's framework mode. Everything below is written for a Remix v2 app, which is still what most production Remix codebases are.
What the production build actually produces
Since Remix 2.7, the default template builds with Vite:
npm run build
# which runs:
remix vite:buildThis produces two outputs, and the distinction matters for deployment:
build/client/— static assets (JS chunks, CSS, fonts) served to the browserbuild/server/index.js— the server bundle that renders your routes
Unlike a static React app, you can't just point a CDN at a folder and walk away. Something has to run the server bundle. The simplest option is the one the default template ships with:
{
"scripts": {
"build": "remix vite:build",
"start": "remix-serve ./build/server/index.js"
}
}remix-serve is a thin Express wrapper that runs your server bundle *and* serves the client assets with sensible cache headers. For most apps it's all you need. If you've ejected to a custom server.js (Express, Fastify, Hono), the same principles apply — just make sure your start command points at your server file, not remix-serve.
Port and host
remix-serve reads the port from the PORT environment variable and falls back to 3000:
PORT=3000 npm startIt binds to all interfaces by default, which is what you want in a container — a server listening only on 127.0.0.1 inside a container is unreachable from the platform's router, and the symptom is a deploy that "succeeds" but times out on every request. If you're running a custom Express server, listen explicitly on 0.0.0.0.
Environment variables the Remix way
Remix has a sharp line between server and browser code, and env vars follow it:
- Server-side (loaders, actions,
entry.server.tsx): readprocess.env.DATABASE_URLdirectly. These never reach the browser. - Browser-side: there is no automatic inlining like
VITE_*orNEXT_PUBLIC_*for your server env. The idiomatic pattern is to expose exactly what you choose from the root loader:
// app/root.tsx
export async function loader() {
return {
ENV: {
PUBLIC_POSTHOG_KEY: process.env.PUBLIC_POSTHOG_KEY,
},
};
}Then serialize ENV onto window in your root component. It's a little manual, but it means you can never leak a secret to the client by accident — the leak has to be typed out by hand.
In production, don't rely on a .env file existing in the image. Set variables on the platform and let the process environment carry them.
The database: Prisma migrations without foot-guns
Most Remix apps I see use Prisma. Two rules for production:
- 1Never run
prisma migrate devagainst a production database. It's a development command that can reset your schema. - 2Use the forward-only command instead:
npx prisma migrate deploymigrate deploy applies pending migrations from prisma/migrations/ and nothing else — no prompts, no drift detection, no resets. Run it as a release step *before* the new version takes traffic, not inside the app's startup path. If two instances boot simultaneously and both try to migrate, you're depending on your migration tool's locking behavior to save you. A separate step removes the race entirely.
Also remember prisma generate needs to run wherever you build, because the generated client is code, not config.
A Dockerfile that works
PandaStack can auto-detect a Node app and build it without a Dockerfile, but if you want full control, this multi-stage build is a solid baseline for Remix + Prisma:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./
COPY --from=build /app/prisma ./prisma
USER node
EXPOSE 3000
CMD ["npx", "remix-serve", "./build/server/index.js"]Details worth noticing:
prismais copied beforenpm ciso the client generates against the right schema, and again into the runtime image somigrate deploycan run there.npm prune --omit=devin the build stage keeps Vite, TypeScript, and the Remix compiler out of the final image.USER node— the app doesn't need root, so don't give it root.
Deploying on PandaStack
The flow on PandaStack looks like this:
- 1Provision the database first. Create a managed PostgreSQL instance (14.x or 16.x are what runs in production) from the dashboard. Daily backups are automatic — 7 days of retention on the free tier, longer on paid plans.
- 2Connect the repo as a container app. Point it at your GitHub repository; the platform detects the Node app and the build, or uses your Dockerfile if one exists at the repo root. Install commands are overridable if you're on pnpm or bun instead of npm.
- 3Attach the database to the app. This is the part that saves you the usual credential shuffle:
DATABASE_URLis injected into the app's environment automatically. Prisma reads it with zero config, becausedatasource db { url = env("DATABASE_URL") }is already the Prisma default. - 4Add your remaining env vars — session secrets, API keys — in the app's environment settings.
- 5Push to Git. The build runs (rootless BuildKit in an ephemeral pod — no shared Docker daemon), and you can watch the build logs stream live, which matters the first time a Prisma generate step fails and you want to see why immediately rather than after a timeout.
For migrations, run npx prisma migrate deploy as a one-off command against the deployed environment before or immediately after the first deploy, and on every deploy that ships a new migration.
Things that bite Remix deploys specifically
- Session secret missing.
createCookieSessionStorageneeds asecretsarray; if you read it from env and forget to set it in production, Remix throws at the first request that touches a session, not at boot. SetSESSION_SECRETbefore your first deploy. devDependenciesin the runtime image. The Vite plugin and@remix-run/devare heavy. If your image is 1.5 GB, you skipped the prune.- Cold starts on the free tier. PandaStack's free tier scales idle apps to zero and wakes them on the next request. Fine for side projects and staging; if you're serving real traffic, use a paid tier that stays warm.
- Serving
build/clientyourself. If you switch fromremix-serveto a custom server, you now own static asset serving. Get the immutable cache headers right forbuild/client/assets/(fingerprinted files) or you'll bust caches on every deploy.
That's the whole path: two-part build, one server process, forward-only migrations, and a database you never had to copy a password for. If you want to see it run, connect a Remix repo at https://pandastack.io and push.