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.tsand its migrations ineasy-cms/migrations(runeasy-cms migrate:create initonce 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-cms3. 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)" > .envAdd 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.comOpen 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 app1Within 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 app2Before going live
Put HTTPS in front: with a domain in the Caddyfile instead of
:80, Caddy gets a certificate by itself. SetSERVER_URLto the public address.Keep instances ×
maxconnections well under Postgres'smax_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.