Keystone 6 is one of the more honest headless CMS options in the Node ecosystem: it's a real TypeScript codebase you own, with a schema you define in code, Prisma underneath for the database layer, and a generated Admin UI (a Next.js app) on top. That "it's just code" quality is exactly why deploying it trips people up — there's no keystone deploy button. You need to get the build, the migrations, and the database connection right, in that order. Here's the whole path.
What Keystone actually produces at build time
Keystone's config lives in keystone.ts at the project root. A minimal production-ready version looks like this:
import { config } from '@keystone-6/core'
import { statelessSessions } from '@keystone-6/core/session'
import { lists } from './schema'
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL!,
},
server: {
port: Number(process.env.PORT) || 3000,
},
lists,
session: statelessSessions({
secret: process.env.SESSION_SECRET,
}),
})Three things in there matter for production:
- 1
provider: 'postgresql'— a lot of Keystone starters ship withprovider: 'sqlite'. SQLite writes to a local file, which disappears when a container restarts. Switch to Postgres before you deploy, not after you lose data. - 2
SESSION_SECRET— in production Keystone requires a session secret of at least 32 characters. Generate one withopenssl rand -hex 32and never commit it. - 3
PORT— Keystone defaults to 3000. Reading it from the environment lets the platform route traffic to whatever port it expects.
The build command is:
npx keystone buildThis does Prisma client generation, GraphQL schema generation, and compiles the Admin UI into the .keystone/ directory. Importantly, keystone build does not connect to your database — Prisma's client generation is offline — so your build environment doesn't need database access. If you're deploying a pure API with no Admin UI, keystone build --no-ui skips the Next.js build and shaves real time off your pipeline.
Your package.json scripts should end up as:
{
"scripts": {
"dev": "keystone dev",
"build": "keystone build",
"start": "keystone start"
}
}Migrations: where Keystone deployments actually fail
Keystone delegates migrations to Prisma Migrate, and the workflow has a sharp edge: keystone dev will happily push schema changes straight to your dev database without creating migration files. That's great locally and a disaster in production, because production needs explicit migration files to replay.
The workflow that works:
Locally, when you change your schema, generate a real migration file:
npx keystone prisma migrate dev --name add_post_statusThis creates a timestamped SQL migration under migrations/. Commit that directory. It is part of your application, not build output.
In production, apply pending migrations with the forward-only command:
npx keystone prisma migrate deploymigrate deploy never generates anything, never prompts, and never resets data — it only applies committed migrations that haven't run yet. It's the only Prisma migrate command that belongs anywhere near production.
Keystone also ships a convenience flag that runs exactly this before boot:
npx keystone start --with-migrationsFor a single-instance deployment this is the simplest correct setup: set your start command to keystone start --with-migrations and migrations apply atomically before the server takes traffic. If you later scale to multiple instances, move migrate deploy into a separate release step so two replicas don't race on the same migration — Prisma takes a lock, so it won't corrupt anything, but one replica will sit waiting.
A Dockerfile for Keystone
PandaStack can build Keystone two ways: auto-detected Node buildpack (zero config — it finds npm run build and npm run start itself) or your own Dockerfile. If you want the Dockerfile:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx keystone build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app ./
EXPOSE 3000
CMD ["npx", "keystone", "start", "--with-migrations"]One deliberate choice here: the runtime stage copies the whole built workspace instead of aggressively pruning dev dependencies. Keystone's runtime needs the generated Prisma client that lives inside node_modules, and pruning around generated artifacts is a classic way to produce an image that works on your machine and crashes in production with Cannot find module '.prisma/client'. Take the slightly larger image; it's the boring, reliable option.
Deploying on PandaStack
The steps, end to end:
1. Provision the database first. In the PandaStack dashboard, create a managed PostgreSQL instance (14.x and 16.x are both available). It's a real managed Postgres — daily scheduled backups plus manual backups, retained 7 days on the free plan, 15 on Pro, 30 on Premium.
2. Connect the repo. Create a container app from your Keystone repository. PandaStack auto-detects the Node app and the build/start commands from package.json; npm, yarn, pnpm, and bun all work as the install step. Or point it at the Dockerfile above — builds run in rootless BuildKit inside ephemeral Kubernetes job pods, so there's no host Docker daemon in the picture.
3. Wire the database. Attach the Postgres instance to the app. This is the part that removes a whole class of mistakes: DATABASE_URL is injected into the app's environment automatically. You never copy a connection string between dashboards, which means you never paste the staging URL into production. Since the Keystone config above already reads process.env.DATABASE_URL, there's nothing else to change.
4. Set the remaining environment variables. In the app's environment settings, add:
SESSION_SECRET=<output of: openssl rand -hex 32>That's genuinely the only required one beyond what's injected.
5. Push.
git push origin mainThe push triggers the build; build logs stream live in the dashboard, so when TypeScript fails compilation you watch it fail in real time instead of refreshing a status page. On success the app deploys, --with-migrations applies your committed migrations against the managed Postgres, and Keystone starts serving. The Admin UI is at /, the GraphQL API at /api/graphql.
6. Create the first user. Keystone's createAuth setup (if you use it) presents an init screen on first visit to the Admin UI when the user table is empty. Do this immediately after first deploy — an empty Keystone instance on a public URL will let whoever visits first create the admin account.
Production gotchas worth knowing in advance
- Scale-to-zero on the free tier. Free-tier apps scale to zero when idle and cold-start on the next request. For a personal CMS this is fine — you eat a cold start when you open the Admin UI, and published content usually sits behind a frontend that caches anyway. If the Admin UI is in daily team use, a paid tier keeps it warm.
- Connection limits. Keystone via Prisma keeps a connection pool. The free-tier database allows 50 connections, which is plenty for one Keystone instance; if you scale replicas up later, do the arithmetic before Postgres does it for you.
- Images and files. Keystone's local file/image storage writes to disk, which is ephemeral in containers. Use Keystone's S3-compatible storage configuration for anything users upload.
- Custom domain. Add your domain in the dashboard and SSL is provisioned automatically — worth doing before you hand editors a URL they'll bookmark.
That's the whole deployment: a schema in code, migrations in Git, one secret in the environment, and a database that wires itself. If you've got a Keystone project sitting in a repo, [pandastack.io](https://pandastack.io) will tell you in one push whether this page was honest.