Repository navigation
Staging and Maintenance
How to run a safe staging copy of a production site, and how to take the public site offline cleanly: the in-app maintenance mode, the static page your reverse proxy serves during a redeploy, and a deploy order with no visible downtime. It is for operators; it assumes the layout described in Deployment.
In depth: docs/STAGING.md and MAINTENANCE.md.
A staging site is a clone of production, and that is the risk: the switches that ought to make it safe (hide from search engines, webhook subscriptions, analytics IDs) are stored in the database, so they arrive with the clone. A fresh copy of a live shop starts out indexable, calling production's webhook receivers and reporting to production's analytics.
STAGING=1(ASTROBAAS_STAGING=1 is accepted as an alias; true, on and yes also work.) With it set, the deployment:
| Effect | Details |
|---|---|
| Stays out of search |
noindex on every page, Disallow: / in robots.txt, an empty sitemap.xml and an empty feed. |
| Fires no webhooks | Outgoing webhook events are dropped before dispatch, so a test order cannot reach a real fulfilment partner. |
| Loads no analytics | No analytics connector is rendered, so production's property never sees test traffic. |
Not covered: /llms.txt and /openapi.json describe the site as usual. Treat them as public if that matters.
The switch is one-way. STAGING=1 can force a site to hide; nothing in the environment can force a site to be indexed. The admin's Settings → Reading screen shows a note when the environment is overriding the stored value.
Because it is an environment variable, a database refresh cannot undo it. That is the point: a setting is data, and data is what a refresh overwrites.
These are environment to begin with, and getting them wrong is quiet:
# Its own everything. Sharing any of these with production is the risk.
AUTH_SECRET=<a different secret>
DATABASE_URL=<its own database> # or DB_PATH for the JSON driver
UPLOADS_DIR=/var/www/shop-staging/shared/uploads
# Off, or pointed somewhere harmless.
SCHEDULER_DISABLED=1
# EMAIL_TRANSPORT unset -> console; nothing reaches a real inbox
# BACKUP_S3_* unset -> staging never writes to production's bucket
# PAYMENTS_ENABLED unset -> no live payment session can be createdNever share AUTH_SECRET between staging and production. Sessions are signed with it, so a shared secret means a session created on staging is valid on production, including one for a test admin account.
Export from production and import into staging, each with an admin session:
# On production: export
curl -sS https://shop.example.com/api/backup/export \
-H "Cookie: $ADMIN_COOKIE" -o backup.json
# On staging: import
curl -sS -X POST https://staging.example.com/api/backup/import \
-H "Cookie: $STAGING_COOKIE" -H 'Content-Type: application/json' \
--data-binary @backup.jsonThis works only when both sides use the same driver:
| Production | Export produces | Staging must run |
|---|---|---|
| JSON (lowdb) | JSON archive (database and uploads) | JSON (lowdb) |
file: SQLite (doc-blob or relational) |
a VACUUM INTO snapshot of the SQLite file |
a file: DATABASE_URL; restart staging afterwards
|
remote libsql:// (Turso) |
refused: there is no local file to copy | use turso db dump and the provider's tooling |
Any other combination is refused with a 400. Across drivers, copy the database with its own tooling and rsync the uploads directories. See Backups and storage and STORAGE.md.
The import brings production's settings and data with it:
- Customer email addresses are now on staging. Either secure staging like production, or scrub the customers, orders and subscribers after the import.
- Re-check Settings → Payments. Stored provider settings travelled with the clone. (Payment secrets themselves are environment-only.)
curl -s https://staging.example.com/robots.txt | grep -q '^Disallow: /$' && echo "hidden OK"
curl -s https://staging.example.com/ | grep -q 'name="robots" content="noindex' && echo "noindex OK"
curl -s -o /dev/null -w '%{http_code}\n' https://staging.example.com/healthz # expect 200
curl -s https://staging.example.com/sitemap.xml | grep -c '<url>' # expect 0Use /healthz over the public hostname; the reference proxy configs restrict /readyz to loopback, so check readiness on the server itself (curl http://127.0.0.1:<port>/readyz).
If the first two checks do not print OK, STAGING=1 is not reaching the process. Check the unit's EnvironmentFile; the admin cannot fix this.
Two different outages need two different answers:
| Kind | The process is | Served by | Covers |
|---|---|---|---|
| In-app | running | AstroBaaS | migrations, plugin installs, planned windows |
| Static | stopped | your reverse proxy | the redeploy itself |
An in-app page cannot cover a redeploy, because during a redeploy there is no app.
MAINTENANCE_MODE=1
MAINTENANCE_MESSAGE="Upgrading the shop, back shortly." # optional
MAINTENANCE_UNTIL=2026-08-25T18:00:00Z # optionalThis is an environment variable because the usual reason to need it is that the database is migrating, busy or unreachable, and a flag stored in the database would be unreadable exactly then. The check runs before anything touches storage or loads a plugin. It needs a restart to turn on or off.
Admin → Settings → Maintenance mode: a checkbox, a message and an optional expected end. Use it for planned work. It needs a healthy database.
The difference: during a scheduled window signed-in staff still see the real site, so you can check it before reopening. The emergency switch answers before any session is read, so it cannot offer that.
-
HTTP 503, with
Retry-After(derived from the expected end when there is one) andCache-Control: no-store. Never 200: a success status tells crawlers the holding page is now your content. - API callers get the same status as JSON with
code: "MAINTENANCE", so a headless storefront does not need to parse HTML.
| Path | Why |
|---|---|
/healthz, /readyz, /metrics
|
A host that cannot health-check the process pulls it out of rotation. |
/api/health/* |
A deploy can assert the deep check before reopening. |
/api/payments/webhook/* |
A payment made just before the window must still be confirmed. Each webhook authenticates itself. |
/admin |
So you can turn maintenance off. |
/login, /logout, /forgot-password, /reset-password, /api/auth/*
|
Signing in is how you reach the admin. |
/_astro/, /favicon*
|
Assets the admin needs. |
Matching is exact or at a / boundary, so a path such as /administrators-guide is not exempt. The list is ALWAYS_OPEN_PATHS in src/lib/maintenance.ts.
Generate the page from the same source as the in-app one:
npm run build:maintenance-page # writes public/maintenance.htmlSet SITE_TITLE in the environment to put your site's name on it (default "This site"). The page is self-contained: no CSS file, no fonts, no JavaScript.
If you deploy with deploy.sh, you do not need to do anything: the page is built into every release, and deploy/remote-activate.sh copies it to shared/maintenance/maintenance.html before the restart. On a server set up another way, copy it there yourself. It must live outside the release directory.
The reference vhost already has this. The relevant part:
location / {
proxy_pass http://astrobaas_cms_example_com;
proxy_intercept_errors on;
# 502 and 504 only, not 503
error_page 502 504 =503 /maintenance.html;
}
location = /maintenance.html {
root /var/www/cms-example-com/shared/maintenance;
internal;
add_header Retry-After 120 always;
add_header Cache-Control "no-store" always;
}This is an excerpt, not a drop-in: astrobaas_cms_example_com is an upstream the full vhost declares.
Two details matter:
-
=503on theerror_pageline is what makes the page a 503 rather than a 200. - Only 502 and 504 are replaced. The app sends 503 on purpose (in-app maintenance, a failing deep health check,
/readyzwhile draining), and intercepting those would hide the JSON a deploy script needs. An app that is really down produces 502 (connection refused) or 504 (timeout).
handle_errors {
@app_down expression `{err.status_code} in [502, 504]`
handle @app_down {
root * /var/www/cms-example-com/shared/maintenance
rewrite * /maintenance.html
header Retry-After 120
header Cache-Control "no-store"
file_server {
status 503
}
}
}
handle_errors only sees errors Caddy produced itself, so the app's own 503s pass through untouched.
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-comIf you would rather visitors saw nothing at all:
- Set
SCHEDULER_DISABLED=1on the old process and restart it, so it stops running background sweeps (scheduled publishing, newsletter batches, abandoned-order cancellation, off-site backup) through a schema that is about to change. - Start the new release alongside it and wait for its
/readyzto return 200. The new process runs the migrations at boot. - Switch the proxy to the new process, then stop the old one.
Do not use MAINTENANCE_MODE=1 for step 1. It returns 503 to every public path from the next request, which is the downtime you are trying to avoid, and it does not stop the scheduler anyway. There is no read-only mode.
Step 1 does not stop writes from requests the old process is still serving (a checkout, an admin save). If the migration you are shipping cannot tolerate that, take a maintenance window instead.
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