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

  1. Wait for the containers to start: docker compose logs -f keystone
  2. Open http://your-server-ip:3000 in your browser
  3. Create the first admin account through the initial setup form
  4. 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

VariablePurposeRequired
DATABASE_URLPostgreSQL/MySQL/SQLite connection stringYes
SESSION_SECRETSecret for session cookie encryption (32+ chars)Yes
NODE_ENVSet to production for optimized modeRecommended

Database Options

DatabaseProvider ValueNotes
PostgreSQLpostgresqlRecommended for production. Case-sensitive queries.
MySQLmysqlSupported. Requires integer auto-increment fields to be indexed.
SQLitesqliteDevelopment 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-For and X-Forwarded-Proto are 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.

Comments