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:
| check | what silently breaks without it |
|---|---|
image_pipeline | sharp’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_url | the 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_writable | the first upload of the day fails, and only then. |
database | a 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_store | a misconfigured shared store fails OPEN: nothing is rate-limited at all, and the config still describes itself as working. |
plugins | a 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.
| Route | Cap | Derived from | Env var |
|---|---|---|---|
/api/media/upload | 111m | MAX_VIDEO_SIZE × 1.1 — src/lib/media/ingest.ts | MEDIA_MAX_VIDEO_MB |
/api/media/replace | 111m | MAX_VIDEO_SIZE × 1.1 — src/lib/media/ingest.ts | MEDIA_MAX_VIDEO_MB |
/api/backup/import | 282m | MAX_ARCHIVE_BYTES × 1.1 — src/lib/backup/offsite.ts | — |
/api/import/wordpress | 26m | MAX_HTTP_WXR_BYTES + 2 MB — src/lib/import/limits.ts | — |
/api/forms/*/upload | 6m | MAX_SUBMISSION_FILE_SIZE × 1.2 — src/lib/media/private-files.ts | — |
| everything else | 2m | BODY_LIMIT_DEFAULT — src/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 back | Who refused it | What to do |
|---|---|---|
JSON, with "code":"PAYLOAD_TOO_LARGE" | the app | The 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 Large | the proxy | The 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-upload | a timeout | 100 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-Proto — TRUST_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.md→ Hardening 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.