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:
# 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"]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"]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
buildscript must not migrate: the image is built without a database. Runnext build(ornuxi build) directly, as above. - The health check calls
<api>/auth/init, which needs the database; the standalone server also answers/healthz. easy-cmsmust be independencies(not onlydevDependencies) for the standalone image, which installs without dev dependencies.- With pnpm, start the build stage with
RUN corepack enable, copypnpm-lock.yamland runpnpm install --frozen-lockfile.
Keep local files out of the image with a .dockerignore:
node_modules
.next
.output
.env
.pglite
uploads
data
*.db
.git2. Compose
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: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:
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 appThe 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.
git pull
docker compose up -d --build4. 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.