RedwoodJS is a full-stack framework with an unusual shape: one repo, two "sides." The web side is a React SPA, the api side is a GraphQL server backed by Prisma. That split is great for development and slightly awkward for deployment, because you have to decide whether to ship one process or two. (Worth knowing: the Redwood team pivoted to a new project, RedwoodSDK, in 2025 — the classic GraphQL framework this tutorial covers continues under community maintenance and still deploys fine.)
The simplest production setup — and the one I'd recommend unless you have a reason not to — is a single container that serves both sides. Here's the whole path: Postgres, build, migrations, Dockerfile, deploy.
Project anatomy, deployment edition
The parts of a Redwood project that matter for shipping:
├── api/
│ ├── db/schema.prisma # Prisma schema — your database lives here
│ └── dist/ # compiled api side (after build)
├── web/
│ └── dist/ # compiled static assets (after build)
├── redwood.toml # ports, apiUrl, env-var exposure
└── .env # local only — never committed, never deployedRedwood is yarn-first (modern versions use Yarn via corepack) and expects Node 20+. Match that in production.
Point Prisma at PostgreSQL
New Redwood projects default to SQLite, which is fine locally and wrong in production. Open api/db/schema.prisma and change the datasource:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}Locally, put a connection string in .env:
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_devThen create your first migration:
yarn rw prisma migrate dev --name initmigrate dev is a development command — it can reset your database when the schema drifts. In production you'll use migrate deploy instead, which only applies pending migrations and never resets anything. Keep that distinction sacred.
The production build
One command builds both sides:
yarn rw prisma generate # explicit, so builds never depend on a stale client
yarn rw buildThis produces web/dist (static assets, hashed filenames) and api/dist (compiled GraphQL functions). Then serve both from a single process:
yarn rw serveyarn rw serve runs Redwood's API server and serves the web assets from the same process, with the web side's GraphQL requests routed to the API under the path configured as apiUrl in redwood.toml. The default web port is 8910. Check your redwood.toml:
[web]
port = 8910
apiUrl = "/.redwood/functions"
[api]
port = 8911With the single-server setup you expose only 8910; the apiUrl stays a relative path so the browser talks to the same origin — no CORS configuration, no second hostname.
Environment variables: the web-side gotcha
This one bites almost everyone once. The api side sees every environment variable via process.env, like any Node process. The web side sees nothing by default — it's a static bundle, and Redwood only inlines variables you explicitly allow:
[web]
includeEnvironmentVariables = ["PUBLIC_ANALYTICS_ID"]Variables prefixed REDWOOD_ENV_ are also exposed to the web side automatically. Two consequences:
- 1Secrets are safe by default —
DATABASE_URLcan't leak into the browser bundle. - 2Web-side variables are baked in at build time. Changing one means rebuilding, not restarting.
A Dockerfile that works
Recent Redwood versions can generate a Dockerfile for you; if you'd rather see exactly what's happening, this minimal one works:
FROM node:20-slim
# Prisma's engines need OpenSSL, which node:*-slim doesn't include
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN corepack enable
COPY . .
RUN yarn install --immutable
RUN yarn rw prisma generate && yarn rw build
ENV NODE_ENV=production
EXPOSE 8910
CMD ["yarn", "rw", "serve"]Two details worth calling out:
- The OpenSSL line is not optional. Prisma's query engine fails on Debian slim images without it, with an error message that does not obviously say "install openssl."
yarn install --immutableis the Yarn Berry equivalent ofnpm ci— it fails the build if the lockfile is out of date instead of silently drifting.
Deploying on PandaStack
Redwood's needs map cleanly onto a container platform with a managed database:
- 1Provision PostgreSQL first. Create a managed Postgres instance (14.x or 16.x are available) from the PandaStack dashboard.
- 2Connect the repo as a container app. With the Dockerfile above in the repo root, the platform builds it — images are built with rootless BuildKit in ephemeral Kubernetes job pods, and the build logs stream live, so when
yarn rw buildfails you watch it fail in real time instead of digging afterwards. - 3Attach the database to the app.
DATABASE_URLis injected into the container's environment automatically — the exact variable Prisma'senv("DATABASE_URL")expects. No credentials to copy, nothing extra to configure. - 4Set remaining env vars in the dashboard:
SESSION_SECRETif you use Redwood's dbAuth, plus any API keys your services need. - 5Push to deploy. Every subsequent
git pushtriggers a build and rollout.
Running migrations in production
Never run migrations inside the container's startup command. Two replicas starting simultaneously will race on the same migration table. Instead, run migrations as a discrete step before the new version takes traffic:
yarn rw prisma migrate deployRun it as a release step or a one-off command against the app's environment (it already has DATABASE_URL). migrate deploy is forward-only and idempotent — running it twice is a no-op, which is exactly the property you want in an automated pipeline.
Gotchas checklist
- SQLite artifacts: if you started on SQLite, delete
api/db/migrationsand re-create migrations against Postgres. Prisma migrations are provider-specific; SQLite-era migration files will fail on Postgres. - Bind address: serving inside a container, make sure the server listens on
0.0.0.0, notlocalhost— otherwise the platform's router can't reach it and health checks fail with a confusing "connection refused." migrate devin prod: worth repeating — it can reset the database. CI and production only ever runmigrate deploy.- Web env changes need rebuilds: if a
REDWOOD_ENV_value looks stale in the browser, you changed the variable but didn't redeploy. - Connection limits: Prisma opens a connection pool per instance. If you scale replicas up, watch your database's connection ceiling and size
connection_limitin theDATABASE_URLquery string accordingly.
That's the whole path: Postgres via one env var, one build command, one container, migrations as a separate forward-only step. If you want to try it with the database wiring handled for you, spin it up on [pandastack.io](https://pandastack.io).