Skip to content
Get Started

Docker

NodePress ships Docker Compose files that run the whole stack on one machine: PostgreSQL, Redis, the API, the admin panel and nginx in front. Use them on a VPS, or locally to try a production-like setup.

File What it is
docker-compose.yml The standard stack. Generates its own secrets on first start.
docker-compose.https.yml Adds Caddy for automatic HTTPS. Combine with docker-compose.yml.
docker-compose.search.yml Adds Meilisearch for search. Combine with docker-compose.yml.
docker-compose.prod.yml A larger standalone stack: PgBouncer, several backend replicas, Prometheus, Grafana and nightly database backups.

The API and admin images come from backend/Dockerfile and frontend/Dockerfile (Node.js 22). You can pull them ready-made or build them yourself.

Every release is published as two images, for linux/amd64 and linux/arm64:

Image What it is
ghcr.io/buildwithkode/nodepress-backend The API
ghcr.io/buildwithkode/nodepress-frontend The admin panel

Each release is tagged with its version (1.9.0), its minor line (1.9), its major line (1) and latest; pre-releases such as 1.9.0-rc.1 get only their own tag. Every image includes an SBOM and build provenance. Set the version to run in .env, so the server doesn’t move to a new release by itself:

.env
NODEPRESS_VERSION=1.9.0

Then docker compose pull && docker compose up -d runs it without building. The one-line installer does this for you.

Releases built by GitHub Actions are signed with Sigstore. Releases published by hand with scripts/release-images.sh (1.8.0 and 1.9.0 among them) are not, so the check below fails for them; their SBOM and provenance are still attached. To check a signed image came from the NodePress repository:

Terminal window
cosign verify ghcr.io/buildwithkode/nodepress-backend:1.9.0 \
--certificate-identity-regexp '^https://github.com/buildwithkode/nodepress/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com

The ready-made images contain stock NodePress only. Build your own when you’ve changed the code or added bundled plugins (plugins uploaded in the admin work with both). The admin’s error reporting to Sentry (NEXT_PUBLIC_SENTRY_DSN) is fixed when the admin is built, so it also needs your own build; the API’s SENTRY_DSN works with either.

Terminal window
docker compose pull && docker compose up -d # ready-made images
docker compose up -d --build # or build from this folder

Open http://localhost/setup (or your server’s address) to create the admin account. Pulling takes about a minute; the first build takes a few minutes.

Service What it does
secrets-init Runs once before the others. Generates JWT_SECRET and the database password into the secrets_data volume, then exits.
db PostgreSQL 16.
redis Redis 7, 128 MB, used for the cache, rate limits and live updates.
backend The API. Runs prisma migrate deploy, then starts.
frontend The admin panel on port 3001 (internal).
nginx Publishes ports 80 and 443 and routes requests. See nginx.

Only nginx publishes ports. The database, Redis and the backend are reachable only inside the Docker network.

You don’t need a .env file to start. On the first up, secrets-init generates a random JWT secret and database password and keeps them in the secrets_data volume; later starts reuse them. To use your own values, set them in a .env file next to docker-compose.yml. A value set there always wins and is written through on every start.

.env
JWT_SECRET=<at least 32 characters>
DB_PASSWORD=<database password>

Changing DB_PASSWORD after the database exists doesn’t change the password inside PostgreSQL. Change it there too (ALTER USER postgres PASSWORD '…').

Docker Compose reads .env in the project folder for these values:

Variable Default Meaning
SITE_URL http://localhost The site’s public URL.
CORS_ORIGIN SITE_URL Allowed browser origins.
APP_URL http://localhost The site’s public URL, without /api. Files on local disk get URLs under <APP_URL>/uploads/.
POSTGRES_DB, POSTGRES_USER nodepress, postgres Database name and user.
JWT_SECRET, DB_PASSWORD generated See Secrets.

The backend container doesn’t read backend/.env: it gets only the variables listed under backend.environment in the Compose file. To set anything else, such as SMTP_*, STORAGE_* or METRICS_TOKEN, add it there. Email and file storage can also be set up in the admin under Settings → Email & storage, with no file changes. See Email and Storage.

docker-compose.https.yml puts Caddy in front of nginx. Caddy gets and renews a Let’s Encrypt certificate for your domain and redirects www.<domain> to it. It needs Docker Compose v2.24 or newer.

  1. Point the domain’s DNS A/AAAA record at the server, and open ports 80 and 443.
  2. Start the stack with both files:
Terminal window
DOMAIN=cms.example.com ACME_EMAIL=you@example.com \
docker compose -f docker-compose.yml -f docker-compose.https.yml up -d --build

You can put DOMAIN and ACME_EMAIL in .env instead. The override sets SITE_URL and CORS_ORIGIN to https://<DOMAIN> and takes ports 80 and 443 from nginx, which keeps running behind Caddy. The one-line installer does all of this for you.

docker-compose.search.yml adds a Meilisearch container and switches the backend’s search to it:

Terminal window
MEILI_MASTER_KEY=$(openssl rand -hex 24) \
docker compose -f docker-compose.yml -f docker-compose.search.yml up -d --build

MEILI_MASTER_KEY must be at least 16 characters; keep it in .env so it stays the same between starts. Build the index once after the first start (later writes keep it current). See Search. Add -f docker-compose.https.yml to combine it with HTTPS.

docker-compose.prod.yml is a separate stack for a bigger server. It needs a .env with at least these values, and refuses to start without the first two:

.env
DB_PASSWORD=<database password>
JWT_SECRET=<at least 32 characters> # openssl rand -hex 48
SITE_URL=https://cms.example.com
CORS_ORIGIN=https://cms.example.com
APP_URL=https://cms.example.com
Terminal window
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d
# or build from this folder: docker compose -f docker-compose.prod.yml up -d --build

It uses the same images as the standard stack; set NODEPRESS_VERSION in .env as above.

Service What it does
db PostgreSQL 16.
redis Redis 7, 256 MB.
pgbouncer Connection pooler in transaction mode. The backend connects through it.
migrate Runs prisma migrate deploy once per up, over a direct connection, before any backend starts.
backend The API. Can run as several replicas.
frontend The admin panel.
nginx Publishes ports 80 and 443.
prometheus, grafana Metrics and dashboards. See Monitoring.
backup Nightly database dump. See Backups.

Every service has a memory limit (the backend: 512 MB and one CPU).

The backend’s DATABASE_URL goes through PgBouncer with ?pgbouncer=true&connection_limit=<DB_POOL_SIZE>. DB_POOL_SIZE (default 10) is the number of connections each backend replica opens. PgBouncer accepts up to 200 client connections and keeps 20 open to PostgreSQL. Migrations use DIRECT_URL, which bypasses PgBouncer.

Terminal window
docker compose -f docker-compose.prod.yml up -d --scale backend=3
docker compose -f docker-compose.prod.yml restart nginx

nginx looks up the backend replicas when it starts, so restart it after changing the count. Redis keeps the cache, rate limits and live updates in sync across replicas. Uploads must be on S3-compatible storage, because each replica would otherwise only see its own disk. See Scaling.

docker-compose.https.yml extends the standard stack, not this one. For the production stack, get a certificate (for example with certbot), uncomment the /etc/letsencrypt volume on the nginx service and the HTTPS server block in nginx/default.conf, and replace YOUR_DOMAIN with your domain.

S3, SMTP and METRICS_TOKEN lines are already in the backend service’s environment, commented out. Uncomment the ones you need and set their values in .env.

nginx/default.conf defines the server, and nginx/proxy.conf holds the routes (mounted at /etc/nginx/snippets/, never under conf.d/). The routes:

Path Goes to Notes
/api/metrics blocked Returns 403. Prometheus scrapes the backend directly.
/api/realtime backend WebSocket upgrade. Each client IP sticks to one replica.
/api/draft, /api/disable-draft frontend Live preview draft mode.
/api/ backend 60 requests per minute per IP, bursts of 30.
/api/media/upload backend 10 uploads per minute per IP, bursts of 10.
/uploads/ backend Cached by browsers for 7 days.
/graphql backend
/sitemap.xml, /robots.txt backend Served from /api/sitemap.xml and /api/robots.txt.
everything else frontend

Request bodies are limited to 50 MB (the API’s own limit for uploads is 10 MB). nginx also adds X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy headers.

nginx runs on the Docker network, so the backend’s default TRUST_PROXY already trusts it. If you put a CDN or another proxy on a public IP in front, see TRUST_PROXY.

Volume Holds Stack
postgres_data The database both
redis_data Redis data both
uploads_data / uploads Uploaded files on local disk standard / production
secrets_data Generated JWT secret and database password standard
caddy_data, caddy_config Certificates HTTPS override
meili_data The Meilisearch index search override
prometheus_data, grafana_data Metrics (kept 15 days) and Grafana settings production
backups Nightly database dumps production
Terminal window
docker compose logs -f backend # follow the API's logs
docker compose ps # what's running
docker compose exec db psql -U postgres nodepress # database shell
docker compose pull && docker compose up -d # after changing NODEPRESS_VERSION
docker compose build && docker compose up -d # rebuild your own images after an upgrade

To upgrade, see Upgrading.