← All posts
GuideOct 25, 2026· 3 min read· Kanawoot K.

Run Easy CMS on several servers with Docker and Postgres

Two Easy CMS instances behind a load balancer, on one Postgres and one bucket, with Docker Compose. Stop either one and the site keeps answering.

One server is enough for most sites. A second one lets you deploy without downtime and keeps the site up when a server fails. This guide sets up two Easy CMS instances with Docker Compose: the official image, Postgres, a bucket for uploads, and Caddy in front.

It uses the standalone server (easy-cms serve). A Next.js or Nuxt app works the same way: run several copies of your app's image instead.

What you need

  • Docker with Compose

  • An S3-compatible bucket and its keys: S3, Cloudflare R2, or MinIO. Uploads can't live on a server's disk once there are two servers.

  • A project with easy-cms.config.ts and its migrations in easy-cms/migrations (run easy-cms migrate:create init once in development)

1. The config

Everything comes from the environment, so both instances run the same image:

import { defineConfig } from '@easy-cms/core'
import { postgres } from '@easy-cms/db-postgres'
import { s3Storage } from '@easy-cms/storage-s3'

export default defineConfig({
  secret: process.env.EASY_CMS_SECRET as string,
  serverURL: process.env.SERVER_URL,
  db: postgres({ url: process.env.DATABASE_URL as string, max: 5 }),
  upload: {
    storage: s3Storage({
      bucket: process.env.S3_BUCKET as string,
      region: process.env.S3_REGION ?? 'auto',
      endpoint: process.env.S3_ENDPOINT,
      accessKeyId: process.env.S3_ACCESS_KEY_ID,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    }),
  },
  collections: [
    { slug: 'posts', drafts: true, schedule: true, fields: [{ name: 'title', type: 'text' }] },
  ],
})

2. The image

The official image holds Easy CMS and its adapters. Yours adds the config and the migrations:

FROM ghcr.io/maritonx/easy-cms:1
COPY easy-cms.config.ts ./
COPY easy-cms ./easy-cms

3. Compose

compose.yaml runs Postgres, a one-off migrate service, two instances and the load balancer:

x-app: &app
  build: .
  environment:
    EASY_CMS_SECRET: ${EASY_CMS_SECRET:?set EASY_CMS_SECRET in .env}
    SERVER_URL: ${SERVER_URL:-http://localhost:8080}
    DATABASE_URL: postgres://cms:${POSTGRES_PASSWORD:?}@db:5432/cms
    S3_BUCKET: ${S3_BUCKET:?}
    S3_REGION: ${S3_REGION:-auto}
    S3_ENDPOINT: ${S3_ENDPOINT:-}
    S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:?}
    S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?}
    # Migrations run once, in the migrate service.
    EASY_CMS_MIGRATE: "false"
  depends_on:
    migrate:
      condition: service_completed_successfully
  restart: unless-stopped

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: cms
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
      POSTGRES_DB: cms
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cms -d cms"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  migrate:
    <<: *app
    entrypoint: ["easy-cms", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  app1: *app
  app2: *app

  lb:
    image: caddy:2
    ports:
      - "8080:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on: [app1, app2]
    restart: unless-stopped

volumes:
  pgdata:

The instances wait for migrate, so the database is up to date before either serves a request, and the migration runs once rather than in both.

4. The load balancer

Caddy sends requests to both instances in turn, and stops sending to one whose /readyz doesn't answer 200:

:80 {
	reverse_proxy app1:4000 app2:4000 {
		lb_policy round_robin
		health_uri /readyz
		health_interval 5s
	}
}

No sticky sessions are needed: sessions, sign-in limits and the job queue live in Postgres, so either instance can answer any request.

5. Start it

Put the secrets in .env next to compose.yaml:

printf 'EASY_CMS_SECRET=%s\nPOSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 16)" > .env

Add S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY, and for R2 or MinIO S3_ENDPOINT too. Then:

docker compose up -d --build
docker compose exec app1 easy-cms admin:create --email you@example.com

Open http://localhost:8080/admin and log in. Uploads go to the bucket, so a file uploaded while one instance answered is served by the other.

6. Check it

Stop one instance and keep using the admin:

docker compose stop app1

Within a few seconds Caddy notices that app1 doesn't answer /readyz and sends everything to app2. Start it again with docker compose start app1.

Deploying a new version

Rebuild, migrate once, then replace the instances one at a time, so one is always serving:

docker compose build
docker compose run --rm migrate
docker compose up -d --no-deps app1
docker compose up -d --no-deps app2

Before going live

  • Put HTTPS in front: with a domain in the Caddyfile instead of :80, Caddy gets a certificate by itself. Set SERVER_URL to the public address.

  • Keep instances × max connections well under Postgres's max_connections, and add PgBouncer when there are many instances. See Connections.

  • Turn on JSON logs (EASY_CMS_LOG_FORMAT=json) if you collect logs, and back up the database and the bucket.

The Reference architecture page lists everything that is supported, and the Docker guide covers a single server.