Repository navigation
Deployment
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.
| 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_EMAILandADMIN_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.
echo "AUTH_SECRET=$(openssl rand -hex 32)" > .env
docker compose up -d --buildWhat the image does:
- builds with
npm ciandnpm run build, then prunes dev dependencies; - runs
node ./dist/server/entry.mjsas a non-rootappuser, withNODE_ENV=production,HOST=0.0.0.0,PORT=4321; - sets
DB_PATH=/app/data/db.json,UPLOADS_DIR=/app/data/uploadsandPRIVATE_UPLOADS_DIR=/app/data/private-uploads, and declares/app/dataas a volume; - sets
SHUTDOWN_TIMEOUT_MS=8000, to fit insidedocker stop's default 10 seconds; - has a
HEALTHCHECKagainst/healthz.
What the compose file adds:
- a named volume for
/app/data; -
AUTH_SECRETfrom.env(compose refuses to start without it); -
SHUTDOWN_TIMEOUT_MS=25000withstop_grace_period: 30s, and an init process as PID 1; - the port published on
127.0.0.1:4321only. A bare4321:4321listens 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.
/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.
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-comKey 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-
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 leastAUTH_SECRET,ADMIN_EMAIL,ADMIN_PASSWORDandHEALTH_TOKEN. Install and edit the systemd unit. -
Prepare production dependencies. Runtime dependencies are not installed on the server.
deploy.shexpects.deploy/node_modulesin your checkout: a Linux x64 tree made withnpm install --omit=devon Linux, copied into.deploy/. Refresh it whenever dependencies change. -
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. -
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'
-
Run the post-deploy checks below.
On your machine:
-
npm run build, and checks thatdist/server/entry.mjsanddist/mail-test.mjsexist; - assembles a release in a temporary directory:
dist/,package.json, the prebuiltnode_modules, and a generatedmaintenance.html; - rsyncs it to
releases/<timestamp>/on the server; - runs
deploy/remote-activate.shon 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).
On the server, as root:
- works out the run user and group from the unit's
User=/Group=(defaultwww-data), and the port from the unit'sEnvironment=PORT=, thenPORT=inshared/.env, thenDEPLOY_PORT; - creates any missing
shared/directories, givesshared/data/to the run user, and leavesshared/.envalone; - makes the release root-owned, enterable (
0755), group-readable and never group-writable; - copies
maintenance.htmlintoshared/maintenance/before the restart; - flips
currentto the new release and runssystemctl restart; - 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; - 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.
| 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.
Two files:
-
deploy/nginx/astrobaas-zones.conf: rate-limit zones, which belong at
http{}level. Install it first, ornginx -tfails withzero size shared memory zone. - deploy/nginx/astrobaas.conf: the vhost.
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 nginxWhat the vhost does:
- redirects port 80 to HTTPS, keeping
/.well-known/acme-challenge/for certificate issuance; - proxies to an
upstreamon127.0.0.1:3002. The upstream name must be unique per vhost on a server; - serves image files under
/uploads/directly fromshared/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
/readyzand/metricsto 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.
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.
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_sizevalues are ceilings that stop unbounded uploads. - Stock Caddy has no rate-limit directive. For an edge limit, build it with the third-party
caddy-ratelimitmodule and uncomment the relevant blocks; otherwise the app's own limiter is the only one.
caddy validate --config /etc/caddy/CaddyfileBehind 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.
- 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; withTRUST_PROXY=1the app readsX-Forwarded-Prototo publishhttps://media URLs.
See SECURITY.md for the operator hardening checklist.
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-429counts 429s from nginx and from the app in the access log. -
astrobaas-nginx-limit-reqandastrobaas-loginuse fail2ban's stocknginx-limit-reqfilter 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
ignoreipif a separate storefront calls the API; banning it takes the shop down.
These are the failures that look fine from the outside. Each is covered in detail in deploy/README.md.
-
Assert the deep health check with
curl -fsS(above). -
Poll
/readyzon loopback before switching traffic. -
Capture the generated admin password from the journal, or set
ADMIN_PASSWORDbeforehand. -
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, setRATE_LIMIT_STORE=libsql. -
Set Admin → Settings → Address of this CMS (
public_site_url), and Site URL. See Configuration. -
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
-
Probe the body limits on upload routes: an unauthenticated POST should get the app's 401, not a proxy 413.
-
Check the maintenance page returns 503: stop the unit,
curlthe site, start it again. -
Confirm the app port is loopback only:
ss -lntp | grep 3002should show127.0.0.1:3002. -
Check media URLs are
https://:curl -s https://cms.example.com/api/products?limit=1 | jq -r .meta.media_base. -
Check nginx serves
/uploads/fromshared/data/: nginx sends a strongETag; a weakW/"..."ETag means the app is serving the file instead. -
Remember build-time variables (
SITE_URL,CSP_*) need a rebuild, not a restart.
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.
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=relationaland 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
schedulerlease runs background sweeps; migrations run under amigrationslease; - never set
SCHEDULER_LEASE=0.
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 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.
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project