AstroBaaS

Run it

Reference deployment

Generated from deploy/README.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.

A bare-metal / VPS deployment of AstroBaaS behind nginx, and the checks worth making after it.

deploy/
  nginx/astrobaas.conf       reference vhost
  systemd/astrobaas.service  reference unit

Both are documentation, not automation: copy them, replace the hostnames and paths, read the comments. They live in the repository so they are versioned with the code that assumes them — three of three CMS hosts on one server were hand-written, and all three reproduced the same rate-limit bug.

The numbers in the vhost are measured, not chosen. See the header of nginx/astrobaas.conf.

Layout

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

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

Post-deploy checklist

These are the things that fail silently — the deployment looks fine, the pages return 200, and something is wrong. Each one has cost a real shop time.

1. Assert the deep health check, don’t eyeball the site

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

-f makes a failure a non-zero exit, so this belongs in the deploy script itself. It returns 503 when anything essential is broken. It checks the things that have actually failed in production, and it EXERCISES them rather than asking whether the module is present:

checkwhat silently breaks without it
image_pipelinesharp’s native module fails to load → uploads get no dimensions and no derivatives, and the storefront is served full-size originals. No error anywhere.
public_site_urlthe CMS does not know its own address → media URLs are guessed from whatever host the request arrived on. Works for the admin, breaks for a storefront on another domain.
uploads_writablethe first upload of the day fails, and only then.
databasea read-only filesystem or a half-finished migration reads perfectly and fails on the first write — which on a shop is the first order.
rate_limit_storea misconfigured shared store fails OPEN: nothing is rate-limited at all, and the config still describes itself as working.
pluginsa paid module active in the database with no implementation loaded. The admin shows it switched on and nothing it provides happens.

Set HEALTH_TOKEN (32+ random characters) in shared/.env so the deploy script can call it without a session. Without a token, only an admin session gets in, and everyone else gets a 404.

Use ?write=0 for a monitor that polls frequently — the write probe rewrites the whole document on the lowdb and libSQL doc drivers.

2. Poll /readyz, not /healthz, before switching traffic over

Migrations run at boot. /healthz answers 200 as soon as the database is readable; /readyz stays 503 until the schema matches the build. Cutting over on /healthz can put traffic on a half-migrated database.

for i in $(seq 30); do curl -fsS http://127.0.0.1:3002/readyz && break; sleep 2; done

3. Capture the generated admin password on a first install

With no ADMIN_PASSWORD set, the first boot in production generates one and prints it once, to stdout. Under systemd that is the journal and nowhere else.

journalctl -u cms-example-com --since '5 minutes ago' | grep -A4 'admin password'

Better: set ADMIN_PASSWORD in shared/.env before the first start.

4. Read the rate-limit store line from the journal

journalctl -u cms-example-com | grep 'rate-limit store'

memory (per-process) is correct for a single node. On two replicas it means each one keeps its own counters, so the effective limit is double what you configured — set RATE_LIMIT_STORE=libsql with a DATABASE_URL.

5. Set the CMS’s own address

Settings → Address of this CMS (public_site_url), e.g. https://cms.example.com.

Leave it empty and media URLs are guessed from the host each request arrives on. That is right for the admin and wrong for a storefront on another domain, a worker, or anything calling the API server-to-server — which is why the deep health check reports it as a warning rather than letting it pass for configuration.

6. Check the body limits on EVERY large-body route, not just uploads

nginx’s default is 1 MB, and a route without its own location inherits the server-level client_max_body_size. Either way the request dies at the proxy: the app never sees it, logs nothing, and cannot explain it in the admin.

This is not hypothetical. A production shop shipped self-hosted video with a vhost that still said client_max_body_size 12m — correct back when the app allowed 10 MB images, untouched when video raised the app’s ceiling to 100 MB. Every video upload returned a bare nginx 413. Three further routes had no location at all and silently inherited 2 MB.

Each cap is the app’s own ceiling, rounded up to the next megabyte. Never round down: a proxy one byte below the app refuses a request the app would have accepted, at the layer least able to say why.

RouteCapDerived fromEnv var
/api/media/upload111mMAX_VIDEO_SIZE × 1.1 — src/lib/media/ingest.tsMEDIA_MAX_VIDEO_MB
/api/media/replace111mMAX_VIDEO_SIZE × 1.1 — src/lib/media/ingest.tsMEDIA_MAX_VIDEO_MB
/api/backup/import282mMAX_ARCHIVE_BYTES × 1.1 — src/lib/backup/offsite.ts
/api/import/wordpress26mMAX_HTTP_WXR_BYTES + 2 MB — src/lib/import/limits.ts
/api/forms/*/upload6mMAX_SUBMISSION_FILE_SIZE × 1.2 — src/lib/media/private-files.ts
everything else2mBODY_LIMIT_DEFAULTsrc/lib/body-limits.ts

The headroom above each file cap is multipart framing: boundary markers and the other form fields count toward Content-Length. A ceiling equal to the file cap refuses an upload of exactly the documented maximum.

The table is generated from nothing — it is maintained by hand, and tests/body-limits.test.mjs fails the build if it stops matching src/lib/body-limits.ts, or if either shipped proxy config would refuse something the app accepts. So if you raise a limit, CI tells you which file you forgot.

Caddy is the opposite problem. Caddy sets no request-body limit at all, so nothing here is needed to make video work; the caps in deploy/caddy/Caddyfile exist to stop an unbounded upload, not to permit a bounded one.

Probe it

curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
  -F file=@some-4mb.jpg https://cms.example.com/api/media/upload

A 401 means it reached the app — the right answer from an unauthenticated probe. To check the video ceiling specifically, send something past the old limit:

head -c 40000000 /dev/urandom > /tmp/probe.bin
curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
  -F file=@/tmp/probe.bin https://cms.example.com/api/media/upload

Telling an app 413 from a proxy 413

They mean opposite things and are fixed in different files. The body is the tell:

What comes backWho refused itWhat to do
JSON, with "code":"PAYLOAD_TOO_LARGE"the appThe file really is over the app’s ceiling. Raise the constant (or MEDIA_MAX_VIDEO_MB) — and the proxy cap with it.
nginx’s HTML 413 Request Entity Too Largethe proxyThe vhost is below the app’s ceiling, or the route has no location. Fix this file. Nothing will appear in the app’s log.
502 / connection reset mid-uploada timeout100 MB over a domestic uplink outlasts a 60s client_body_timeout. The upload routes raise theirs to 300s.
# Which one was it? The app answers in JSON; nginx answers in HTML.
curl -sk -X POST -F file=@big.mp4 https://cms.example.com/api/media/upload | head -c 200

An app 413 is also visible in the journal; a proxy 413 appears only in /var/log/nginx/*.error.log. Silence in journalctl -u astrobaas with a 413 at the client is conclusive: it was the proxy.

7. Ship the maintenance page, and check it is a 503

npm run build:maintenance-page   # writes public/maintenance.html
# copy it to shared/maintenance/maintenance.html — NOT inside a release

It is generated, not committed, so the vhost’s @maintenance path 404s until somebody puts it there. And it must answer 503, never 200: a holding page served as 200 tells a crawler that this is the content now.

sudo systemctl stop cms-example-com
curl -sk -o /dev/null -w '%{http_code}\n' https://cms.example.com/   # expect 503
sudo systemctl start cms-example-com

8. Confirm the app is not reachable except through nginx

TRUST_PROXY=1 tells the app to believe X-Forwarded-For. If the port is also reachable directly, anyone can set that header themselves and get a forged client IP — a fresh rate-limit bucket per request, and a login throttle counting against somebody else’s address.

ss -lntp | grep 3002        # expect 127.0.0.1:3002, never 0.0.0.0:3002

9. Check the images come back as https://, not http://

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

An http:// answer on an https site means the app is not seeing X-Forwarded-ProtoTRUST_PROXY=1 is missing from the unit, or the vhost is not setting the header. Those image URLs are mixed content and a browser will refuse to load them, so the images fail on the storefront while looking correct in the API.

Setting Address of this CMS removes the guesswork entirely: a declared value wins over anything derived from the request.

10. Point nginx’s /uploads/ at the shared directory

Not at a release’s dist/client/uploads. That is a build-time snapshot; every file uploaded since the build would 404, and only for the images customers actually look at.

A plain curl -I proves nothing here: try_files falls back to the app, so the file is served either way and a 200 does not tell you WHO served it. Look at the headers instead — nginx sets a strong ETag of the form "<hex>-<hex>" and the app sets a weak one (W/"…"):

curl -skI https://cms.example.com/uploads/<a-recently-uploaded-file>.webp | grep -i etag

A weak W/"…" means nginx did not find the file and the app is serving every image — usually because the alias points at a release’s dist/client/uploads instead of the shared directory.

11. Build-time variables have to be set at BUILD time

SITE_URL and every CSP_* variable are read by astro.config.ts when the bundle is built. Putting them in the systemd unit or shared/.env does nothing. A checklist step that says “set CSP_IMG_SRC and restart” is wrong.

The runtime site_url setting does win over the build-time SITE_URL for canonical links, the sitemap and feeds — that one is editable in the admin.

12. npm run setup and reset-password ignore your database configuration

Both hardcode ./db.json and ignore DB_PATH and DATABASE_URL. On a libSQL-backed deployment they silently operate on the wrong file. Use the admin UI, or run them against the right database by hand.

Also read

  • SECURITY.mdHardening checklist for operators — TLS, secrets, headers. This checklist deliberately does not repeat it.
  • MAINTENANCE.md — the maintenance window mechanism and which paths must stay reachable so an operator can turn it back off.