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.
Compose files
Section titled “Compose files”| 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.
Ready-made images
Section titled “Ready-made images”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:
NODEPRESS_VERSION=1.9.0Then 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:
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.comThe 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.
The standard stack
Section titled “The standard stack”docker compose pull && docker compose up -d # ready-made imagesdocker compose up -d --build # or build from this folderOpen 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.
Secrets
Section titled “Secrets”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.
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 '…').
Configuration
Section titled “Configuration”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.
HTTPS with Caddy
Section titled “HTTPS with Caddy”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.
- Point the domain’s DNS
A/AAAArecord at the server, and open ports 80 and 443. - Start the stack with both files:
DOMAIN=cms.example.com ACME_EMAIL=you@example.com \ docker compose -f docker-compose.yml -f docker-compose.https.yml up -d --buildYou 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.
Meilisearch
Section titled “Meilisearch”docker-compose.search.yml adds a Meilisearch container and switches the backend’s search to it:
MEILI_MASTER_KEY=$(openssl rand -hex 24) \ docker compose -f docker-compose.yml -f docker-compose.search.yml up -d --buildMEILI_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.
The production stack
Section titled “The production stack”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:
DB_PASSWORD=<database password>JWT_SECRET=<at least 32 characters> # openssl rand -hex 48SITE_URL=https://cms.example.comCORS_ORIGIN=https://cms.example.comAPP_URL=https://cms.example.comdocker 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 --buildIt 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).
Connection pooling
Section titled “Connection pooling”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.
More backend replicas
Section titled “More backend replicas”docker compose -f docker-compose.prod.yml up -d --scale backend=3docker compose -f docker-compose.prod.yml restart nginxnginx 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.
Extra settings
Section titled “Extra settings”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.
Volumes
Section titled “Volumes”| 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 |
Common commands
Section titled “Common commands”docker compose logs -f backend # follow the API's logsdocker compose ps # what's runningdocker compose exec db psql -U postgres nodepress # database shelldocker compose pull && docker compose up -d # after changing NODEPRESS_VERSIONdocker compose build && docker compose up -d # rebuild your own images after an upgradeTo upgrade, see Upgrading.