How to Self-Host KeystoneJS with Docker Compose
What Is KeystoneJS?
KeystoneJS is a Node.js headless CMS and application framework. You define a data schema in TypeScript or JavaScript, and Keystone automatically generates a GraphQL API and a React-based admin UI. It uses Prisma as its ORM, which means it works with PostgreSQL, MySQL, and SQLite. The project is maintained by Thinkmill and has been in development since 2014, with Keystone 6 being the current major version.
Prerequisites
- A Linux server (Ubuntu 22.04+ recommended)
- Docker and Docker Compose installed (guide)
- 2 GB of RAM minimum
- 10 GB of free disk space
- Node.js 18+ (for local development and project scaffolding)
- A domain name (optional, for remote access)
Project Setup
KeystoneJS requires a project with a schema definition. Create one locally:
npm init keystone-app@latest my-keystone-app
cd my-keystone-app
This generates a project with keystone.ts (schema definition), package.json, and TypeScript configuration. The schema file defines your content lists (collections), fields, and access control rules.
Docker Compose Configuration
KeystoneJS doesn’t have an official Docker image — you build your own from your project. Create a Dockerfile in the project root:
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production=false
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.keystone ./.keystone
COPY --from=builder /app/schema.graphql ./schema.graphql
COPY --from=builder /app/keystone.ts ./keystone.ts
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/schema.prisma ./schema.prisma
EXPOSE 3000
CMD ["yarn", "start"]
Create a docker-compose.yml file:
services:
keystone:
build: .
container_name: keystone
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://keystone:changeme-strong-password@postgres:5432/keystone # CHANGE password
SESSION_SECRET: changeme-generate-with-openssl-rand-hex-32 # CHANGE THIS
NODE_ENV: production
networks:
- keystone-net
postgres:
image: postgres:16-alpine
container_name: keystone-db
restart: unless-stopped
environment:
POSTGRES_USER: keystone
POSTGRES_PASSWORD: changeme-strong-password # Must match DATABASE_URL above
POSTGRES_DB: keystone
volumes:
- keystone-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keystone"]
interval: 10s
timeout: 5s
retries: 5
networks:
- keystone-net
volumes:
keystone-db:
networks:
keystone-net:
Build and start:
docker compose up -d --build
The build takes 1–3 minutes. Keystone runs database migrations automatically on first start via Prisma.
Initial Setup
- Wait for the containers to start:
docker compose logs -f keystone - Open
http://your-server-ip:3000in your browser - Create the first admin account through the initial setup form
- The admin UI is available at the root URL, and the GraphQL playground at
/api/graphql
Configuration
Schema Definition
Content models are defined in keystone.ts:
import { config, list } from '@keystone-6/core';
import { text, relationship, timestamp, select } from '@keystone-6/core/fields';
import { document } from '@keystone-6/fields-document';
import { allowAll } from '@keystone-6/core/access';
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL!,
},
lists: {
Post: list({
access: allowAll,
fields: {
title: text({ validation: { isRequired: true } }),
content: document({
formatting: true,
links: true,
dividers: true,
layouts: [
[1, 1],
[1, 1, 1],
],
}),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
publishedAt: timestamp(),
author: relationship({ ref: 'User.posts', many: false }),
},
}),
User: list({
access: allowAll,
fields: {
name: text({ validation: { isRequired: true } }),
email: text({ validation: { isRequired: true }, isIndexed: 'unique' }),
posts: relationship({ ref: 'Post.author', many: true }),
},
}),
},
session: {
maxAge: 60 * 60 * 24 * 30,
secret: process.env.SESSION_SECRET!,
},
});
After changing the schema, rebuild the Docker image to apply migrations.
Key Environment Variables
| Variable | Purpose | Required |
|---|---|---|
DATABASE_URL | PostgreSQL/MySQL/SQLite connection string | Yes |
SESSION_SECRET | Secret for session cookie encryption (32+ chars) | Yes |
NODE_ENV | Set to production for optimized mode | Recommended |
Database Options
| Database | Provider Value | Notes |
|---|---|---|
| PostgreSQL | postgresql | Recommended for production. Case-sensitive queries. |
| MySQL | mysql | Supported. Requires integer auto-increment fields to be indexed. |
| SQLite | sqlite | Development only. File-based — mount as volume in Docker. |
Reverse Proxy
Place KeystoneJS behind a reverse proxy with SSL:
- Forward Port:
3000 - Proxy Headers: Ensure
X-Forwarded-ForandX-Forwarded-Protoare passed
See our Reverse Proxy Setup guide for configuration examples.
Backup
Back up the PostgreSQL database:
docker compose exec postgres pg_dump -U keystone keystone > keystone-backup.sql
If using file uploads, back up the upload directory (configure storage in keystone.ts with a named volume).
For a comprehensive strategy, see our Backup Strategy guide.
Troubleshooting
Prisma migration fails on startup
Symptom: Error: P3009: migrate found failed migrations in logs.
Fix: This occurs when a previous migration was interrupted. Connect to the database and clear the failed migration record from _prisma_migrations, then restart the container.
GraphQL playground returns 404
Symptom: Navigating to /api/graphql shows a 404 error.
Fix: The GraphQL playground is only available in development mode by default. In production, use a GraphQL client (like Insomnia or Postman) to query the endpoint directly at /api/graphql.
Admin UI blank after schema change
Symptom: The admin panel shows errors or missing fields after updating keystone.ts.
Fix: Schema changes require rebuilding the Docker image (docker compose up -d --build). Keystone generates the admin UI at build time, not runtime.
Database connection timeout
Symptom: Error: P1001: Can't reach database server on startup.
Fix: Ensure PostgreSQL is ready before Keystone starts. The depends_on with health check in the Compose file handles this. Verify the DATABASE_URL matches your PostgreSQL credentials exactly.
Resource Requirements
- RAM: 300 MB idle, 500 MB – 1 GB under load (plus PostgreSQL)
- CPU: Low for content management. Medium during builds.
- Disk: 400 MB for application + dependencies, plus database storage
Verdict
KeystoneJS fills a specific niche: developers who want a schema-driven CMS with a GraphQL API and don’t want to write the CRUD layer themselves. Its Prisma-based data layer is clean, the admin UI respects access control rules, and the document field is a solid rich text editor.
The limitation is the same as other code-first CMSs — no official Docker image, schema changes require rebuilds, and non-developers can’t modify the content model without touching code. For a pull-and-run headless CMS, Directus or Strapi are simpler to self-host. For a more complete framework with REST + GraphQL and built-in auth, Payload CMS offers a more modern alternative with a larger community.
Choose KeystoneJS if you want a GraphQL-first CMS with a clean schema definition API and Prisma under the hood.
Related
- How to Self-Host Payload CMS with Docker Compose
- How to Self-Host Directus with Docker Compose
- How to Self-Host Strapi with Docker Compose
- Directus vs Strapi: Best Headless CMS
- Payload vs Directus: Which Headless CMS?
- Strapi vs Directus vs Payload: Headless CMS Showdown
- Best Self-Hosted CMS Platforms
- Docker Compose Basics
- Reverse Proxy Setup
Get self-hosting tips in your inbox
Get the Docker Compose configs, hardware picks, and setup shortcuts we don't put in articles. Weekly. No spam.
Comments