Skip to content

Staging and Maintenance

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

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.

Part 1: running a staging copy

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.

Set one variable

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.

The rest of the staging environment

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 created

Never 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.

Refreshing staging from production

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.json

This 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.

After every refresh

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.)

Assert it

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 0

Use /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.

Part 2: maintenance mode

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.

The emergency switch (environment)

MAINTENANCE_MODE=1
MAINTENANCE_MESSAGE="Upgrading the shop, back shortly."   # optional
MAINTENANCE_UNTIL=2026-08-25T18:00:00Z                     # optional

This 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.

The scheduled window (admin)

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.

What visitors get

  • HTTP 503, with Retry-After (derived from the expected end when there is one) and Cache-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.

What stays reachable

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.

Part 3: the static page for redeploys

Generate the page from the same source as the in-app one:

npm run build:maintenance-page     # writes public/maintenance.html

Set 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.

nginx

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:

  • =503 on the error_page line 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, /readyz while 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).

Caddy

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.

Check it

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

Part 4: a deploy with no visible downtime

If you would rather visitors saw nothing at all:

  1. Set SCHEDULER_DISABLED=1 on 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.
  2. Start the new release alongside it and wait for its /readyz to return 200. The new process runs the migrations at boot.
  3. 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.

See also

Clone this wiki locally