KeystoneJS is one of the most flexible headless CMS frameworks in the Node.js world. Unlike turnkey systems, Keystone is *code-first*: you define your schema in TypeScript, and it generates an Admin UI plus a GraphQL API. That power comes with a deployment catch — Keystone needs a real database, runs a build step that bakes the Admin UI, and expects a long-running Node process. This guide walks through getting a production Keystone 6 instance live.
What Keystone needs in production
Before touching any platform, it helps to enumerate Keystone's runtime requirements:
- A SQL database. Keystone uses Prisma under the hood and supports PostgreSQL, MySQL, and SQLite. SQLite is fine for prototypes but unsuitable for a stateless container — use PostgreSQL in production.
- A build step.
keystone buildcompiles the Admin UI (a Next.js app) and generates the Prisma client. This must run before the app starts. - Migrations. In production you should run
prisma migrate deployrather than letting Keystone push schema changes automatically. - A persistent secret.
SESSION_SECRETmust be stable across restarts or every user gets logged out on redeploy.
Step 1: Configure Keystone for PostgreSQL
In your keystone.ts, point the database config at an environment variable and enable migrations:
import { config } from '@keystone-6/core';
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL!,
// run prisma migrate deploy yourself in production
prismaClientPath: 'node_modules/.prisma/client',
},
server: {
port: Number(process.env.PORT) || 3000,
},
// ...lists, session, etc.
});The key detail: bind to process.env.PORT. Most managed platforms inject a port and expect your app to listen on it.
Step 2: Make the build reproducible
Keystone projects work cleanly with auto-detected Node buildpacks, but being explicit avoids surprises. Add these scripts to package.json:
{
"scripts": {
"build": "keystone build",
"start": "keystone start --with-migrations",
"postinstall": "keystone postinstall --fix"
}
}keystone start --with-migrations runs prisma migrate deploy automatically at boot, which is exactly what you want in an ephemeral container — the container starts, applies any pending migrations, then serves traffic.
Step 3: Deploy on PandaStack
PandaStack auto-detects Node projects, so the flow is genuinely "push code, it runs."
- 1Create a PostgreSQL database from the dashboard (Keystone 6 works well with PostgreSQL 16.x). PandaStack injects a
DATABASE_URLinto your service automatically when you link the database — which is the exact variable Keystone reads. - 2Create a container app and connect your Git repo. The buildpack detects Node, runs
npm install, then yourbuildscript. - 3Set environment variables:
SESSION_SECRET=<a long random string, 32+ chars>
NODE_ENV=production
# DATABASE_URL is auto-wired from the linked database- 1Set the start command to
npm start(which maps tokeystone start --with-migrations).
That's it. On push, PandaStack runs the build in a rootless BuildKit job, pushes the image to its registry, and Helm-deploys it. You get live build logs while it compiles the Admin UI.
Handling file uploads
Keystone's local file/image storage writes to disk, which does not survive container restarts. For production, route uploads to S3-compatible object storage. Keystone supports this natively:
storage: {
s3_images: {
kind: 's3',
type: 'image',
bucketName: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
endpoint: process.env.S3_ENDPOINT, // for MinIO/R2
forcePathStyle: true,
},
},If you want to keep everything self-hosted, pair Keystone with a MinIO instance (see our MinIO deployment guide). Point S3_ENDPOINT at your MinIO service URL.
Production checklist
| Concern | Recommendation |
|---|---|
| Database | Managed PostgreSQL, not SQLite |
| Migrations | prisma migrate deploy via --with-migrations |
| Sessions | Stable SESSION_SECRET env var |
| Uploads | S3-compatible storage, never local disk |
| Admin UI | Restrict via Keystone access control, not just obscurity |
| Secrets | Never commit .env; use platform env vars |
Common pitfalls
- "Prisma client not generated" — usually means
postinstalldidn't run. Ensure the script is present and the build didn't skip dev dependencies. - Admin UI 404s after deploy — the
keystone buildstep didn't complete. Check build logs; Keystone needs enough memory to compile the Next.js Admin UI. Bump to a larger compute tier if the build OOMs. - Logged out on every deploy —
SESSION_SECRETis being auto-generated per boot. Set it explicitly.
Wrapping up
Keystone is a fantastic choice when you want a CMS you can shape with code rather than a config UI. The deployment story comes down to three things: a managed Postgres database, a build step that compiles the Admin UI, and stable secrets. Get those right and Keystone runs happily as a stateless container.
PandaStack's free tier includes container apps and a managed database, which is enough to run a small Keystone instance end to end. Connect your repo and the DATABASE_URL wiring happens for you — spin one up at https://dashboard.pandastack.io.
References
- KeystoneJS documentation: https://keystonejs.com/docs
- Keystone production deployment guide: https://keystonejs.com/docs/guides/production
- Keystone file storage / S3 config: https://keystonejs.com/docs/config/config#storage
- Prisma migrate deploy: https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production
- PostgreSQL 16 release notes: https://www.postgresql.org/docs/16/release-16.html