Skip to content

Deployment

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How to run AstroBaaS in production on a Linux server you control, using the reference files shipped in the repository: deploy.sh, deploy/remote-activate.sh, a systemd unit, nginx or Caddy configs, and fail2ban examples. It also covers Docker, health endpoints, TLS and rollback. It is for operators; read Installation and Configuration first.

The reference configs are documentation, not automation: copy them, replace the hostnames and paths, and read the comments. They are versioned with the code, and tests/deploy-artifacts.test.mjs checks them in CI. The complete guide is deploy/README.md.

Two supported shapes

Shape Use Files
Docker (or Coolify, or anything that builds a Dockerfile) Point the platform at the repository. Dockerfile, docker-compose.yml
Bare metal / VPS with systemd and a reverse proxy, zero-downtime releases A Linux server you own. deploy.sh, deploy/

Either way, before exposing the site:

  • set a real AUTH_SECRET (openssl rand -hex 32);
  • set ADMIN_EMAIL and ADMIN_PASSWORD, or be ready to read the generated password from the log on first boot;
  • serve it over HTTPS (production cookies are Secure);
  • put the database and uploads on persistent storage;
  • set any build-time variables (SITE_URL, CSP_*, ANALYTICS_PROVIDERS) where the build runs, not only on the server.

Docker

echo "AUTH_SECRET=$(openssl rand -hex 32)" > .env
docker compose up -d --build

What the image does:

  • builds with npm ci and npm run build, then prunes dev dependencies;
  • runs node ./dist/server/entry.mjs as a non-root app user, with NODE_ENV=production, HOST=0.0.0.0, PORT=4321;
  • sets DB_PATH=/app/data/db.json, UPLOADS_DIR=/app/data/uploads and PRIVATE_UPLOADS_DIR=/app/data/private-uploads, and declares /app/data as a volume;
  • sets SHUTDOWN_TIMEOUT_MS=8000, to fit inside docker stop's default 10 seconds;
  • has a HEALTHCHECK against /healthz.

What the compose file adds:

  • a named volume for /app/data;
  • AUTH_SECRET from .env (compose refuses to start without it);
  • SHUTDOWN_TIMEOUT_MS=25000 with stop_grace_period: 30s, and an init process as PID 1;
  • the port published on 127.0.0.1:4321 only. A bare 4321:4321 listens on every interface, and Docker's own iptables rules bypass host firewalls such as ufw.

The compose file passes only AUTH_SECRET, NODE_ENV and SHUTDOWN_TIMEOUT_MS into the container. Add the other variables you need (database, email, payments, TRUST_PROXY=1 behind your proxy) to its environment: block. Put a reverse proxy on the same host in front of it for TLS.

Server layout (bare metal)

/var/www/<slug>/
  releases/<timestamp>/          one build each
  current -> releases/<ts>/      symlink, flipped on deploy
  shared/.env                    secrets, 0640 root:<app group>
  shared/data/                   database
  shared/data/uploads/           UPLOADS_DIR
  shared/data/private-uploads/   PRIVATE_UPLOADS_DIR (form attachments), 0750
  shared/maintenance/            maintenance.html, readable while the app is down

Everything the app writes lives in shared/. A deploy swaps current; if uploads moved with the release, every image uploaded since the last build would 404.

The systemd unit

deploy/systemd/astrobaas.service is the reference unit. Install it under your site's slug:

sudo cp deploy/systemd/astrobaas.service /etc/systemd/system/cms-example-com.service
# replace cms-example-com and /var/www/cms-example-com throughout, then:
sudo systemctl daemon-reload
sudo systemctl enable --now cms-example-com

Key settings, and why:

Setting Value Why
EnvironmentFile shared/.env Secrets stay out of the unit file, which is world-readable. Create it with sudo install -o root -g www-data -m 0640 /dev/null /var/www/<slug>/shared/.env.
HOST / PORT 127.0.0.1 / 3002 Loopback only. This is what makes TRUST_PROXY=1 safe.
TRUST_PROXY 1 Real client IPs for rate limits, and https:// media URLs behind TLS.
DB_PATH, UPLOADS_DIR, PRIVATE_UPLOADS_DIR under shared/data/ The defaults point inside the release, which is read-only and replaced on each deploy.
NODE_OPTIONS --max-old-space-size=512 Heap ceiling sized against MemoryMax=1G. Raise them together.
SHUTDOWN_TIMEOUT_MS / TimeoutStopSec 25000 / 30 The drain budget must be shorter than systemd's stop timeout.
ExecStart /usr/bin/node ./dist/server/entry.mjs Run from WorkingDirectory=/var/www/<slug>/current.
StartLimitIntervalSec / StartLimitBurst 60 / 5 (in [Unit]) A bad AUTH_SECRET becomes a visible failed unit instead of an endless restart loop.
ProtectSystem=strict + ReadWritePaths shared/ only The app cannot rewrite its own code. If you move data paths elsewhere, list them here.
MemoryDenyWriteExecute no sharp needs it; yes breaks every image upload.
LimitNOFILE 65536 Avoids EMFILE under load.

deploy.sh reads User= and Environment=PORT= from this unit, so keep them in the unit rather than in .env.

Validate before starting:

sudo systemd-analyze verify /etc/systemd/system/cms-example-com.service

First deploy

  1. Prepare the server. Install Node 22.12+ and nginx (or Caddy). Create /var/www/<slug>/shared/.env (root-owned, 0640, group of the app user) with at least AUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD and HEALTH_TOKEN. Install and edit the systemd unit.

  2. Prepare production dependencies. Runtime dependencies are not installed on the server. deploy.sh expects .deploy/node_modules in your checkout: a Linux x64 tree made with npm install --omit=dev on Linux, copied into .deploy/. Refresh it whenever dependencies change.

  3. Set the deploy target without editing the committed script:

    cp deploy.local.sh.example deploy.local.sh    # gitignored
    # edit DEPLOY_SSH_HOST and DEPLOY_SLUG, then:
    ./deploy.local.sh

    Or pass the values directly: DEPLOY_SSH_HOST=myserver DEPLOY_SLUG=cms-example-com ./deploy.sh.

  4. Watch the first boot for the generated admin password if you did not set ADMIN_PASSWORD:

    journalctl -u cms-example-com --since '5 minutes ago' | grep -A4 'admin password'
  5. Run the post-deploy checks below.

What deploy.sh does

On your machine:

  1. npm run build, and checks that dist/server/entry.mjs and dist/mail-test.mjs exist;
  2. assembles a release in a temporary directory: dist/, package.json, the prebuilt node_modules, and a generated maintenance.html;
  3. rsyncs it to releases/<timestamp>/ on the server;
  4. runs deploy/remote-activate.sh on the server over ssh.

Optional variables: DEPLOY_PORT (the app's port), DEPLOY_RUN_USER (the user the app runs as), DEPLOY_KEEP (releases to keep, default 5), DEPLOY_URL (printed at the end).

What remote-activate.sh does

On the server, as root:

  1. works out the run user and group from the unit's User=/Group= (default www-data), and the port from the unit's Environment=PORT=, then PORT= in shared/.env, then DEPLOY_PORT;
  2. creates any missing shared/ directories, gives shared/data/ to the run user, and leaves shared/.env alone;
  3. makes the release root-owned, enterable (0755), group-readable and never group-writable;
  4. copies maintenance.html into shared/maintenance/ before the restart;
  5. flips current to the new release and runs systemctl restart;
  6. polls http://127.0.0.1:<port>/readyz (30 tries, 2 seconds apart) and fails the deploy if the release never becomes ready, printing the unit status, recent journal lines and the rollback command;
  7. prunes old releases, keeping the newest DEPLOY_KEEP (at least 2) and never removing the current or previous one.

If you place a release by hand, run the ownership and permission steps from step 3 yourself before moving current.

Health and readiness endpoints

Endpoint Purpose Access
/healthz Liveness: the process and its storage answer. Cheap. Public. The reference nginx vhost rate-limits it (10 a minute per address).
/readyz Readiness: 200 only when storage is reachable and the schema is at the version this build expects. 503 while migrating, while the database is stuck below the expected version, or while the process drains for shutdown. Returns schema_version and expected_schema_version. Public in the app; the reference nginx and Caddy configs restrict it to loopback (403 elsewhere).
/api/health/deep Exercises the essentials: image pipeline, uploads writable, a database write, the rate-limit store, plugins, email channel, off-site backup, public_site_url and more. 503 when anything essential is broken; warnings do not fail it. Admin session, or Authorization: Bearer $HEALTH_TOKEN. Everyone else gets 404.
/metrics Prometheus metrics. 404 unless METRICS_ENABLED=1; the reference configs restrict it to loopback.

Use /readyz, not /healthz, to decide whether a new release may take traffic: migrations run at boot, and /healthz answers 200 before they finish.

Assert the deep check from your deploy script:

curl -fsS -H "Authorization: Bearer $HEALTH_TOKEN" \
  https://cms.example.com/api/health/deep | jq '{ok, status, failed, warnings}'

Add ?write=0 for a monitor that polls often; the write probe rewrites the whole document on the lowdb and doc-blob drivers.

Reverse proxy

nginx

Two files:

sudo cp deploy/nginx/astrobaas-zones.conf /etc/nginx/conf.d/00-astrobaas-zones.conf
# copy and edit the vhost: hostname, /var/www path, upstream name, port
sudo nginx -t && sudo systemctl reload nginx

What the vhost does:

  • redirects port 80 to HTTPS, keeping /.well-known/acme-challenge/ for certificate issuance;
  • proxies to an upstream on 127.0.0.1:3002. The upstream name must be unique per vhost on a server;
  • serves image files under /uploads/ directly from shared/data/, falling back to the app;
  • sets per-route body limits matching the app's own ceilings (below);
  • rate-limits generously at the edge (a flood control, not a policy: one catalogue page can issue hundreds of requests), more tightly on /api/auth/login, and answers 429;
  • restricts /readyz and /metrics to loopback;
  • turns an unreachable app (502/504) into the static maintenance page with status 503.

On nginx older than 1.25.1 (Debian 12, Ubuntu 24.04), replace http2 on; with listen 443 ssl http2;. If your server block includes certbot's options-ssl-nginx.conf, drop the vhost's own ssl_* lines.

Body limits

nginx defaults to a 1 MB request body, and a route without its own location inherits the server-level limit. A request refused there never reaches the app and is not logged by it.

Route nginx cap Derived from
/api/media/upload, /api/media/replace 111m MEDIA_MAX_VIDEO_MB (100) x 1.1
/api/backup/import 282m maximum archive size x 1.1
/api/import/wordpress 26m maximum WXR size + 2 MB
/api/forms/*/upload 6m maximum form attachment x 1.2
everything else 2m the app's default body limit

If you raise MEDIA_MAX_VIDEO_MB, raise the proxy cap with it. A JSON 413 with "code":"PAYLOAD_TOO_LARGE" came from the app; an HTML 413 came from nginx.

Caddy

deploy/caddy/Caddyfile is the same shape. Differences:

  • Caddy obtains and renews certificates itself; there is no certbot step.
  • Caddy has no default body limit, so the request_body max_size values are ceilings that stop unbounded uploads.
  • Stock Caddy has no rate-limit directive. For an edge limit, build it with the third-party caddy-ratelimit module and uncomment the relevant blocks; otherwise the app's own limiter is the only one.
caddy validate --config /etc/caddy/Caddyfile

Behind a CDN

Behind Cloudflare or another CDN, every request arrives from the CDN's addresses. Enable the commented real-IP block in the nginx vhost (set_real_ip_from for the CDN's ranges, real_ip_header CF-Connecting-IP;), or the trusted_proxies block and header_up X-Forwarded-For {client_ip} line in the Caddyfile. Otherwise one rate-limit bucket and one login throttle are shared by all visitors. Do not let the CDN cache /api/ or /admin, and exempt /api/payments/webhook/* from any challenge. deploy/README.md also covers firewalling the origin to the CDN and restricting /admin by address.

TLS

  • The reference nginx vhost expects Let's Encrypt certificates at /etc/letsencrypt/live/<host>/ and allows TLS 1.2 and 1.3 only. Obtain them with certbot (the vhost keeps the ACME challenge path on port 80).
  • Caddy handles certificates automatically.
  • The app itself speaks plain HTTP on loopback. Production cookies are Secure, so the proxy must terminate HTTPS; with TRUST_PROXY=1 the app reads X-Forwarded-Proto to publish https:// media URLs.

See SECURITY.md for the operator hardening checklist.

fail2ban

deploy/fail2ban/ has a filter and example jails that ban addresses producing repeated 429s:

sudo cp deploy/fail2ban/filter.d/astrobaas-429.conf /etc/fail2ban/filter.d/
sudo cp deploy/fail2ban/jail.d/astrobaas.local /etc/fail2ban/jail.d/
# edit log paths and ignoreip, then test and reload:
sudo fail2ban-regex /var/log/nginx/cms.example.com.access.log /etc/fail2ban/filter.d/astrobaas-429.conf
sudo fail2ban-client reload && sudo fail2ban-client status astrobaas-429
  • astrobaas-429 counts 429s from nginx and from the app in the access log.
  • astrobaas-nginx-limit-req and astrobaas-login use fail2ban's stock nginx-limit-req filter on the error log.
  • 503 is deliberately not a trigger (deploys and maintenance produce 503s for everyone).
  • Always put your storefront server's address in ignoreip if a separate storefront calls the API; banning it takes the shop down.

Post-deploy checklist

These are the failures that look fine from the outside. Each is covered in detail in deploy/README.md.

  1. Assert the deep health check with curl -fsS (above).

  2. Poll /readyz on loopback before switching traffic.

  3. Capture the generated admin password from the journal, or set ADMIN_PASSWORD beforehand.

  4. Check the rate-limit store line in the journal (journalctl -u <unit> | grep 'rate-limit store'). memory (per-process) is right for one process; with replicas, set RATE_LIMIT_STORE=libsql.

  5. Set Admin → Settings → Address of this CMS (public_site_url), and Site URL. See Configuration.

  6. Send one real email with the release's own test, under the same environment the service uses:

    sudo systemd-run --pipe --wait --quiet --uid=www-data --gid=www-data \
      -p EnvironmentFile=/var/www/<slug>/shared/.env \
      -p WorkingDirectory=/var/www/<slug>/current \
      /usr/bin/node dist/mail-test.mjs you@example.com
  7. Probe the body limits on upload routes: an unauthenticated POST should get the app's 401, not a proxy 413.

  8. Check the maintenance page returns 503: stop the unit, curl the site, start it again.

  9. Confirm the app port is loopback only: ss -lntp | grep 3002 should show 127.0.0.1:3002.

  10. Check media URLs are https://: curl -s https://cms.example.com/api/products?limit=1 | jq -r .meta.media_base.

  11. Check nginx serves /uploads/ from shared/data/: nginx sends a strong ETag; a weak W/"..." ETag means the app is serving the file instead.

  12. Remember build-time variables (SITE_URL, CSP_*) need a rebuild, not a restart.

Restarts and graceful shutdown

On SIGTERM (systemctl restart, docker stop, a deploy) the app answers /readyz with 503, stops its scheduler, waits for in-flight requests, writes buffered view counts and exits 0. A second signal exits at once. Each restart logs one "type":"shutdown" JSON line; abandoned_requests above 0 means a request outlived SHUTDOWN_TIMEOUT_MS.

More than one process

The reference unit runs a single process, which is the tested shape. If you run more (a second unit, PM2 cluster mode, replicas on other hosts):

  • use DATABASE_DRIVER=relational and a database every process reaches (file: on one host, libsql:// across hosts), plus shared uploads;
  • set RATE_LIMIT_STORE=libsql;
  • only the holder of the scheduler lease runs background sweeps; migrations run under a migrations lease;
  • never set SCHEDULER_LEASE=0.

Backups

Keep at least one backup off the machine: the built-in off-site backup to an S3-compatible bucket (BACKUP_S3_*), or a nightly copy of shared/data/ taken consistently (for SQLite, use .backup or VACUUM INTO, never a plain copy beside a live -wal file). Back up shared/.env separately and encrypted. See Backups and storage.

Rollback

Rollback is manual. If a new release never becomes ready, remote-activate.sh stops and prints the exact command, of this form:

ln -sfn '/var/www/<slug>/releases/<previous>' '/var/www/<slug>/current' && systemctl restart '<slug>'

It is not automatic on purpose: by then the new code may have migrated the database, and running older code against a newer schema is a decision, not a reflex. Migrations are forward-only (there are no down migrations), so before rolling back after a migration, check UPGRADE.md for that release, and consider restoring the backup taken before the deploy. See Upgrading.

The previous release is always kept by the pruning step, so the symlink target exists.

See also

Clone this wiki locally