Skip to content

Deploy

View markdown

Run KDIGI CMS in production: Docker image, Coolify, any VPS with Docker Compose, plus backups, health checks and troubleshooting.

KDIGI CMS is one Next.js server (admin + API on port 3000) and a PostgreSQL database. Uploaded media is stored on a persistent volume, or in Cloudflare R2 per site (media and storage). Your public websites are separate frontends that call the API (REST and GraphQL API).

PieceDetails
ImageDockerfile: Node 22 Alpine, multi-stage build, runs next start on port 3000 as the unprivileged node user. The image keeps src/ and node_modules because the entrypoint runs payload migrate and the optional demo seed.
Entrypointdocker/entrypoint.sh: (1) generate/persist PAYLOAD_SECRET in DATA_DIR if unset, (2) payload migrate unless SKIP_MIGRATIONS=true, (3) demo seed if SEED_DEMO=true, (4) start the server.
DatabasePostgreSQL (the compose files use postgres:16-alpine). Use the bundled db service or any managed PostgreSQL via DATABASE_URI.
Volumespostgres_data (database), media_data → /app/media (uploads), app_data → /app/data (generated secret).
HealthGET /api/health is a liveness probe only: it answers while the process serves HTTP and does not query the database. Both compose files use it for the container healthcheck (start period 90 s).

Minimum production settings: PAYLOAD_SECRET (≥ 32 chars), PAYLOAD_PUBLIC_SERVER_URL (your https:// URL), a database. Everything else is in configuration.

docker-compose.coolify.yml is written for Coolify's "Docker Compose" resource type.

  1. In Coolify: New Resource → Docker Compose, pick this repository (or your fork), set Docker Compose Location to /docker-compose.coolify.yml.
  2. Add the environment variable PAYLOAD_SECRET (openssl rand -hex 32). It is the only required one; Coolify generates the domain, TLS certificate and the database password (SERVICE_URL_APP_3000, SERVICE_PASSWORD_POSTGRES).
  3. Attach your domain to the app service (port 3000). If you use a custom domain, also set PAYLOAD_PUBLIC_SERVER_URL=https://cms.example.com — otherwise admin login and media URLs use the generated Coolify domain.
  4. Optional variables the compose file forwards: R2_OFF, ENABLE_JOBS_AUTORUN, FRONTEND_BASE_URL, DEEPSEEK_API_KEY, DEEPSEEK_MODEL, RECAPTCHA_SECRET, MODERATION_WEBHOOK_URL, OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, SEED_DEMO, SKIP_MIGRATIONS. A variable that is not listed in the compose environment: block does not reach the container — add it there if you need another one.
  5. Deploy. The first start applies all migrations; open https://cms.example.com/admin and create the first user (it becomes super-admin).

External database instead of the bundled one: set DATABASE_URI and remove the db service together with the app's depends_on entry.

Coolify tip: keep media_data in your server backup (below). Redeploys never touch named volumes.

Scheduled backups and tasks on Coolify:

  • Coolify's built-in scheduled database backups are offered for databases created as their own Coolify resource, not for the db service inside a Docker Compose resource. For the bundled db, run pg_dump from a Coolify Scheduled Task on the db service, or from a cron job on the Docker host (docker exec <db container> pg_dump -U kdigi --format=custom kdigi > /backups/kdigi-$(date +%F).dump, then delete dumps older than 14 days), and copy the dumps off the server.
  • Any recurring job for the app (for example pnpm demo:reset on a public demo) is a Coolify Scheduled Task on the app service with a cron expression and timezone. The runtime image includes pnpm, src/ and the dev dependencies, so the command is simply pnpm demo:reset (equivalent: node --import tsx src/seed/demo-reset.ts).

Requirements: a Linux server with Docker + Compose plugin, 2 GB RAM or more (the image build is memory hungry; the build script allows up to 8 GB of heap, so build on a bigger machine or add swap if it runs out of memory), and a domain pointing to the server.

Terminal window
git clone https://github.com/kdigivn/kdigi-cms-open.git
cd kdigi-cms-open
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-a-long-random-password
PAYLOAD_SECRET=change-me-to-a-random-string-of-32-chars-or-more
PAYLOAD_PUBLIC_SERVER_URL=https://cms.example.com
ENABLE_JOBS_AUTORUN=true
EOF
docker compose up -d --build

docker-compose.yml reads that .env (POSTGRES_PASSWORD, PAYLOAD_SECRET, PAYLOAD_PUBLIC_SERVER_URL, ENABLE_JOBS_AUTORUN and the other optional variables from configuration). Use PAYLOAD_SECRET explicitly in production so you control and back it up.

The app listens on host port APP_PORT (default 3000) on all interfaces. Put a TLS-terminating reverse proxy in front and firewall the port, e.g. with Caddy:

cms.example.com {
reverse_proxy 127.0.0.1:3000
}

nginx works the same way: proxy to 127.0.0.1:3000, forward Host and X-Forwarded-Proto, and allow large request bodies (client_max_body_size 50m;) for media uploads.

Update later with git pull && docker compose up -d --build (migrations run on start; see upgrading).

Do not run SEED_DEMO=true on a real production install: it creates a demo site and sample content.

Node.js ≥ 20.9, pnpm 10 and PostgreSQL are required.

Terminal window
pnpm install --frozen-lockfile
export DATABASE_URI=postgres://kdigi:change-me@127.0.0.1:5432/kdigi
export PAYLOAD_SECRET=... PAYLOAD_PUBLIC_SERVER_URL=https://cms.example.com NODE_ENV=production
pnpm build
pnpm migrate
pnpm start

Run it under a process manager (systemd, pm2) and set MEDIA_DIR to a persistent directory. pnpm build skips lint and type checking by design; run pnpm typecheck and pnpm lint yourself when you change code.

Saving content queues a "revalidate frontend" job (the CMS calls https://<site domain>/api/cms/revalidate so your frontend refreshes its cache). Jobs only execute when ENABLE_JOBS_AUTORUN=true, which must be set on exactly one instance. With a single container, simply set it. Failed jobs stay in the payload_jobs table with hasError/error and are not browsable through the REST API; inspect them with SQL. See architecture.

Back up both the database and the media, and keep the PAYLOAD_SECRET you use (without it existing sessions and API keys stop working, but data stays readable).

Database (bundled PostgreSQL, run from the directory with the compose file):

Terminal window
docker compose exec -T db pg_dump -U kdigi --format=custom kdigi > kdigi-$(date +%F).dump
# restore into an empty database:
docker compose exec -T db pg_restore -U kdigi -d kdigi --clean --if-exists < kdigi-2026-01-01.dump

Media on local disk:

Terminal window
docker compose exec -T app tar czf - -C /app/media . > media-$(date +%F).tgz
# restore:
docker compose exec -T app tar xzf - -C /app/media < media-2026-01-01.tgz

Sites that use Cloudflare R2 keep their files in the bucket; enable bucket versioning/replication on the Cloudflare side. Test a restore on a scratch machine before you need it. For managed PostgreSQL use the provider's snapshots.

Rollback of a bad release: redeploy the previous image only if it understands the current schema; otherwise restore the database backup taken before the upgrade (upgrading).

  • Create the first user immediately. On an empty database anyone who can reach /admin can submit the "create first user" form and becomes super-admin. Open the admin right after the first deploy (or restrict access at the proxy until you have).
  • Connections: each instance uses a pool of up to 20 PostgreSQL connections; budget max_connections for instances plus CLI runs (payload migrate). Connect timeout is 10 s; idle-in-transaction sessions are reaped after 120 s.
  • One schema at a time: push is disabled; the database only changes through migrations (upgrading).
  • Multiple sites need no redeploy: add a site in the admin, then configure that site's frontend and domain (multi-site).
  • Logs: application logs go to stdout. Set OTEL_EXPORTER_OTLP_ENDPOINT to export traces.
SymptomCheck
Container restarts at boot with PAYLOAD_SECRET must be at least 32 charactersSet PAYLOAD_SECRET to ≥ 32 characters (or unset it with Docker and let the entrypoint generate one).
Admin login loops, 403 on API callsPAYLOAD_PUBLIC_SERVER_URL must equal the exact origin in the browser address bar (scheme, host, port).
App cannot connect to the databaseDATABASE_URI host/port/password; in Compose use host db, not localhost.
Healthy container but admin errors about missing tables/columnsMigrations did not run or failed: check the container log, run docker compose exec app node_modules/.bin/payload migrate.
Uploaded images vanish after redeploy/app/media is not on a persistent volume (or set MEDIA_DIR to one).
Images 404 or point at the wrong hostPAYLOAD_PUBLIC_SERVER_URL is the media URL prefix; see media and storage.
Frontend does not refresh after publishingENABLE_JOBS_AUTORUN=true on one instance, the site's domain is set, and the frontend exposes /api/cms/revalidate.
Build killed / out of memoryBuild on a larger host or add swap; the build needs several GB of memory.
Build is green but code is brokennext build skips lint and type checking; run pnpm typecheck and pnpm lint.
Comment endpoint receives spamSet RECAPTCHA_SECRET (configuration).