# Run Easy CMS on several servers: Docker Compose, Postgres, S3 and a load balancer

> A step-by-step setup with Docker Compose: two Easy CMS instances from the official image, Postgres, S3 storage and Caddy as the load balancer, with migrations run once.

URL: https://easy-cms-website.vercel.app/blog/run-easy-cms-on-several-servers  
Published: 2026-10-25  
Updated: 2026-10-28

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:

```ts
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:

```dockerfile
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:

```yaml
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`:

```txt
: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`:

```bash
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:

```bash
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:

```bash
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:

```bash
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](/docs/databases#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](/docs/deploy/production) page lists everything that is supported, and the [Docker guide](/docs/deploy/docker) covers a single server.
