Back to Blog
Tutorial7 min read2026-07-12

How to Deploy a Remix App with PostgreSQL

Deploy Remix to production: the Vite build, remix-serve and PORT, Prisma migrations, a working Dockerfile, and a managed Postgres wired in via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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:build

This produces two outputs, and the distinction matters for deployment:

  • build/client/ — static assets (JS chunks, CSS, fonts) served to the browser
  • build/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 start

It 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): read process.env.DATABASE_URL directly. These never reach the browser.
  • Browser-side: there is no automatic inlining like VITE_* or NEXT_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:

  1. 1Never run prisma migrate dev against a production database. It's a development command that can reset your schema.
  2. 2Use the forward-only command instead:
npx prisma migrate deploy

migrate 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:

  • prisma is copied before npm ci so the client generates against the right schema, and again into the runtime image so migrate deploy can run there.
  • npm prune --omit=dev in 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:

  1. 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.
  2. 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.
  3. 3Attach the database to the app. This is the part that saves you the usual credential shuffle: DATABASE_URL is injected into the app's environment automatically. Prisma reads it with zero config, because datasource db { url = env("DATABASE_URL") } is already the Prisma default.
  4. 4Add your remaining env vars — session secrets, API keys — in the app's environment settings.
  5. 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. createCookieSessionStorage needs a secrets array; 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. Set SESSION_SECRET before your first deploy.
  • devDependencies in the runtime image. The Vite plugin and @remix-run/dev are 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/client yourself. If you switch from remix-serve to a custom server, you now own static asset serving. Get the immutable cache headers right for build/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.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also