Skip to content

Deploy with Docker ​

What you'll build

A container image of your site with Easy CMS, run with Docker Compose: with Postgres in a second container, or with SQLite on a volume. The same image runs on any server or platform that runs containers.

Before this page: Choosing a host, Migrations & deployment.

A container keeps running, so the server runs scheduled jobs every minute by itself, and SQLite and uploads can live on volumes. Building inside the image also puts SQLite's native driver on the right system.

1. The Dockerfile ​

The image migrates the database when it starts, then serves the site. Pick your setup:

dockerfile
# Build: install everything and build Next.js. No database is needed to build.
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx next build

# Run: the built app with its node_modules (the easy-cms CLI runs the migrations).
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build /app ./
EXPOSE 3000
HEALTHCHECK --interval=30s --start-period=30s \
  CMD node -e "fetch('http://localhost:3000/api/cms/auth/init').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "npx easy-cms migrate && npx next start"]
dockerfile
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx nuxi build

FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOST=0.0.0.0
COPY --from=build /app ./
EXPOSE 3000
HEALTHCHECK --interval=30s --start-period=30s \
  CMD node -e "fetch('http://localhost:3000/api/cms/auth/init').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "npx easy-cms migrate && node .output/server/index.mjs"]
dockerfile
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production PORT=4000
EXPOSE 4000
HEALTHCHECK --interval=30s --start-period=20s \
  CMD node -e "fetch('http://localhost:4000/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "npx easy-cms migrate && npx easy-cms serve --trust-proxy"]
  • The build script must not migrate: the image is built without a database. Run next build (or nuxi build) directly, as above.
  • The health check calls <api>/auth/init, which needs the database; the standalone server also answers /healthz.
  • easy-cms must be in dependencies (not only devDependencies) for the standalone image, which installs without dev dependencies.
  • With pnpm, start the build stage with RUN corepack enable, copy pnpm-lock.yaml and run pnpm install --frozen-lockfile.

Keep local files out of the image with a .dockerignore:

txt
node_modules
.next
.output
.env
.pglite
uploads
data
*.db
.git

2. Compose ​

yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      EASY_CMS_SECRET: ${EASY_CMS_SECRET:?set EASY_CMS_SECRET in .env}
      EASY_CMS_SETUP_CODE: ${EASY_CMS_SETUP_CODE:-}
      DATABASE_URL: postgres://cms:cms@db:5432/cms
    volumes:
      - uploads:/app/uploads
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: cms
      POSTGRES_PASSWORD: cms
      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

volumes:
  uploads:
  pgdata:
yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      EASY_CMS_SECRET: ${EASY_CMS_SECRET:?set EASY_CMS_SECRET in .env}
      EASY_CMS_SETUP_CODE: ${EASY_CMS_SETUP_CODE:-}
      DATABASE_URL: file:/app/data/cms.db
    volumes:
      - data:/app/data
      - uploads:/app/uploads
    restart: unless-stopped

volumes:
  data:
  uploads:

The config reads the database from DATABASE_URL, for example postgres({ url: process.env.DATABASE_URL }) or sqlite({ url: process.env.DATABASE_URL }). With SQLite, run one container only: a SQLite file has one server.

Put the secrets in a .env file next to compose.yaml (not in the image), then start:

bash
printf 'EASY_CMS_SECRET=%s\nEASY_CMS_SETUP_CODE=%s\n' "$(openssl rand -hex 32)" "choose-a-phrase" > .env
docker compose up -d --build
docker compose logs -f app

The log shows the applied migrations, then the server. Open http://localhost:3000/admin and create the first admin with the setup code. Change the Postgres password before using this anywhere but your machine.

3. Updating ​

Pull the new code and rebuild: the new container applies pending migrations before it starts, and the volumes keep the database and uploads.

bash
git pull
docker compose up -d --build

4. HTTPS and the proxy ​

Put a reverse proxy in front for HTTPS: Caddy, Traefik, nginx, or your platform's. It sets X-Forwarded-For, so trust it: --trust-proxy (standalone, already in the image above) or trustProxy: true (Next.js route handlers, Nuxt module). Then set serverURL to the public address. The proxy should also redirect HTTP to HTTPS. A Caddy setup is in VPS.

Backups ​

The volumes hold everything: back up the pgdata or data volume and uploads, or let Easy CMS make database backups itself (backups in the config, stored on another volume or in S3). See Backups.

Next steps ​

  • VPS: run the same site without containers.
  • Other hosts: platforms that build and run this image for you.

Released under the MIT License.