Validation & Smoke Tests
Canonical infrastructure validation commands for staging and local environments.
Validation & Smoke Tests
Copy-paste-safe commands for validating ByteBite infrastructure after changes. Variables use names that are not Bash special variables.
Staging commands verified: 2026-09-08
HTTPS application hosts require HTTP BasicAuth. Set
STAGING_BASIC_AUTH_USERandSTAGING_BASIC_AUTH_PASSWORDin your shell (never commit). HTTP redirects, DNS, and TLS checks do not need credentials.
Staging — DNS
dig +short edge.getbytebite.co
dig +short api.staging.getbytebite.co
dig +short staging.getbytebite.co
TENANT_HOST="smoke-$(date +%s).staging.getbytebite.co"
dig +short "${TENANT_HOST}"Expected: all resolve to 88.99.0.118 (directly or via edge.getbytebite.co).
Staging — TLS
Explicit host
echo | openssl s_client -connect api.staging.getbytebite.co:443 -servername api.staging.getbytebite.co 2>/dev/null | openssl x509 -noout -subject -issuer -datesWildcard host
TENANT_HOST="smoke-$(date +%s).staging.getbytebite.co"
echo | openssl s_client -connect "${TENANT_HOST}:443" -servername "${TENANT_HOST}" 2>/dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltNameExpected: valid Let's Encrypt certificate covering *.staging.getbytebite.co.
Staging — HTTP → HTTPS redirect
curl -sI "http://staging.getbytebite.co/" | head -5
curl -sI "http://api.staging.getbytebite.co/health" | head -5Expected: 301 or 308 redirect to https://. No BasicAuth prompt on HTTP.
Staging — unauthenticated HTTPS is 401
curl -sI "https://api.staging.getbytebite.co/health" | head -15
curl -sI "https://docs.staging.getbytebite.co/" | head -15
curl -sI "https://dashboard.staging.getbytebite.co/" | head -15
curl -sI "https://luigi.staging.getbytebite.co/" | head -15
curl -sI "https://dokploy.getbytebite.co/" | head -8Expected for ByteBite staging hosts: 401 and www-authenticate: Basic realm="ByteBite Staging".
dokploy.getbytebite.co must not use this realm (Dokploy has its own login).
Staging — Health endpoints
: "${STAGING_BASIC_AUTH_USER:?set staging basic-auth user}"
: "${STAGING_BASIC_AUTH_PASSWORD:?set staging basic-auth password}"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://api.staging.getbytebite.co/health"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://api.staging.getbytebite.co/health/db"Expected: {"status":"ok","service":"bytebite-api"} and db status ok.
Staging — Explicit hosts
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://staging.getbytebite.co/" | head -3
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://docs.staging.getbytebite.co/" | head -3
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://dashboard.staging.getbytebite.co/" | head -3
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://api.staging.getbytebite.co/health"All should return successful responses with valid TLS.
Staging — Same-origin API
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://luigi.staging.getbytebite.co/api/health"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://mario.staging.getbytebite.co/api/health"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://dashboard.staging.getbytebite.co/api/health"Expected: {"status":"ok","service":"bytebite-api"}.
Staging — Seeded tenants
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://luigi.staging.getbytebite.co/api/storefront/restaurant" | head -c 200
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://mario.staging.getbytebite.co/api/storefront/restaurant" | head -c 200
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://luigi.staging.getbytebite.co/api/storefront/hours"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://mario.staging.getbytebite.co/api/storefront/hours"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://luigi.staging.getbytebite.co/api/storefront/menu"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://mario.staging.getbytebite.co/api/storefront/menu"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://luigi.staging.getbytebite.co/api/storefront/cart"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://mario.staging.getbytebite.co/api/storefront/cart"Expected: restaurant JSON for each host, including hours.windows. Luigi's hours are not Mario's. Luigi's menu contains Margherita and not Carbonara. Mario's menu is the inverse. Inactive seed items (Secret Calzone, Secret Lasagna) are omitted. Empty carts are 200 with items: []. Pickup checkout is 409 when closed (empty week or outside windows); cart/menu GETs stay 200. After migrate, checkout isolation (BasicAuth required):
LUIGI_MARGHERITA_ID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5"
# Add Margherita, copy bb_cart from Set-Cookie, then:
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "content-type: application/json" \
-H "cookie: bb_cart=<luigi-cart-uuid>" \
-d '{"name":"Anna","phone":"+491111111111","restaurantId":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"}' \
"https://luigi.staging.getbytebite.co/api/storefront/checkout"
# Luigi order must snapshot Margherita at 850/1700 and ignore body restaurantId.
# Replay the Luigi orderId on Mario:
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-o /dev/null -w "%{http_code}" \
"https://mario.staging.getbytebite.co/api/storefront/orders/<luigi-order-uuid>"
# Expected: 404. Unknown host checkout/order routes are also 404.seed-staging is not required for orders unless the restaurant/menu schema is missing. Seeds do not create orders.
Accept / reject isolation (after a Luigi checkout). Body restaurantId must be ignored:
LUIGI_ID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"
MARIO_ID="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "content-type: application/json" \
-d '{"etaMinutes":30,"restaurantId":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"}' \
"https://dashboard.staging.getbytebite.co/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/accept"
# Expected: accepted, etaMinutes 30, snapshot still 850/1700.
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-o /dev/null -w "%{http_code}" \
-H "content-type: application/json" \
-d '{"etaMinutes":30}' \
"https://dashboard.staging.getbytebite.co/api/dashboard/restaurants/${MARIO_ID}/orders/<luigi-order-uuid>/accept"
# Expected: 404. Luigi order stays pending if this ran before the Luigi accept.Kitchen isolation (after Luigi accept). Body restaurantId must be ignored:
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "content-type: application/json" \
-d '{"restaurantId":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"}' \
"https://dashboard.staging.getbytebite.co/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/preparing"
# Expected: preparing. ETA 30 and snapshot 850/1700 unchanged.
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "content-type: application/json" \
-d '{}' \
"https://dashboard.staging.getbytebite.co/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/ready"
# Expected: ready.
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "content-type: application/json" \
-d '{}' \
"https://dashboard.staging.getbytebite.co/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/complete"
# Expected: completed, unpaid/cash.
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-o /dev/null -w "%{http_code}" \
-H "content-type: application/json" \
-d '{}' \
"https://dashboard.staging.getbytebite.co/api/dashboard/restaurants/${MARIO_ID}/orders/<luigi-order-uuid>/preparing"
# Expected: 404. Luigi order is unchanged if this ran before the Luigi kitchen writes.
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
"https://luigi.staging.getbytebite.co/api/storefront/orders/<luigi-order-uuid>"
# Expected: completed (after the Luigi kitchen path).
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-o /dev/null -w "%{http_code}" \
"https://mario.staging.getbytebite.co/api/storefront/orders/<luigi-order-uuid>"
# Expected: 404.Cookie isolation (after migrate; seeds do not create carts):
LUIGI_MARGHERITA_ID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5"
curl -s -D - -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "content-type: application/json" \
-d "{\"menuItemId\":\"${LUIGI_MARGHERITA_ID}\"}" \
"https://luigi.staging.getbytebite.co/api/storefront/cart/items"
# Copy bb_cart from Set-Cookie, then replay it on Mario:
curl -s -D - -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" \
-H "cookie: bb_cart=<luigi-cart-uuid>" \
"https://mario.staging.getbytebite.co/api/storefront/cart"Expected: Mario returns an empty cart for Mario's restaurantId and a new bb_cart Set-Cookie. The Set-Cookie has no Domain attribute (host-only). Secure is set on staging.
Staging — Wildcard tenant
TENANT_HOST="smoke-$(date +%s).staging.getbytebite.co"
# DNS
dig +short "${TENANT_HOST}"
# TLS
echo | openssl s_client -connect "${TENANT_HOST}:443" -servername "${TENANT_HOST}" 2>/dev/null | openssl x509 -noout -subject
# HTTP redirect
curl -sI "http://${TENANT_HOST}/" | head -3
# Unauthenticated HTTPS (expect 401)
curl -sI "https://${TENANT_HOST}/" | head -8
# Storefront (unknown tenant)
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://${TENANT_HOST}/" | head -5
# Same-origin API health (no tenant required)
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://${TENANT_HOST}/api/health"
# Tenant-aware API (unknown tenant)
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" -o /dev/null -w "%{http_code}" "https://${TENANT_HOST}/api/storefront/restaurant"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" -o /dev/null -w "%{http_code}" "https://${TENANT_HOST}/api/storefront/menu"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" -o /dev/null -w "%{http_code}" "https://${TENANT_HOST}/api/storefront/cart"
curl -s -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" -o /dev/null -w "%{http_code}" "https://${TENANT_HOST}/api/storefront/checkout"Expected:
| Check | Expected |
|---|---|
| DNS | 88.99.0.118 |
| TLS | Valid wildcard cert |
| HTTP | Redirect to HTTPS |
| Unauthenticated HTTPS | HTTP 401, Basic realm ByteBite Staging |
| Storefront | HTTP 200, "Restaurant not found" (known application gap) |
/api/health | HTTP 200, {"status":"ok",...} |
/api/storefront/restaurant | HTTP 404 |
/api/storefront/menu | HTTP 404 |
/api/storefront/cart | HTTP 404 |
/api/storefront/checkout | HTTP 404 |
Staging — Reserved host precedence
After wildcard rollout, explicit hosts must still work:
for HOST in api.staging.getbytebite.co docs.staging.getbytebite.co dashboard.staging.getbytebite.co luigi.staging.getbytebite.co mario.staging.getbytebite.co; do
echo "=== ${HOST} ==="
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://${HOST}/" | head -2
doneStaging — Public port checks
From an external machine (not the APP VM):
nc -z -w 3 88.99.0.118 80 && echo "80 OPEN" || echo "80 CLOSED"
nc -z -w 3 88.99.0.118 443 && echo "443 OPEN" || echo "443 CLOSED"
nc -z -w 3 88.99.0.118 3000 && echo "3000 OPEN" || echo "3000 CLOSED"
nc -z -w 3 88.99.0.118 3001 && echo "3001 OPEN" || echo "3001 CLOSED"
nc -z -w 3 88.99.0.118 3002 && echo "3002 OPEN" || echo "3002 CLOSED"
nc -z -w 3 88.99.0.118 5432 && echo "5432 OPEN" || echo "5432 CLOSED"
nc -z -w 3 88.99.0.118 8080 && echo "8080 OPEN" || echo "8080 CLOSED"Expected: 80 and 443 OPEN; all others CLOSED.
Staging — noindex headers
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://staging.getbytebite.co/" | grep -i robots
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://luigi.staging.getbytebite.co/" | grep -i robots
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://dashboard.staging.getbytebite.co/" | grep -i robots
curl -sI -u "${STAGING_BASIC_AUTH_USER}:${STAGING_BASIC_AUTH_PASSWORD}" "https://docs.staging.getbytebite.co/" | grep -i robotsExpected: X-Robots-Tag: noindex, nofollow.
Local — basic health
With pnpm dev running:
curl -s "http://api.bytebite.test/health"
curl -s "http://luigi.bytebite.test/api/health"
curl -s "http://dashboard.bytebite.test/api/health"Local — seeded menus and carts
curl -s "http://luigi.bytebite.test/api/storefront/menu"
curl -s "http://mario.bytebite.test/api/storefront/menu"
curl -s "http://luigi.bytebite.test/api/storefront/cart"
curl -s "http://mario.bytebite.test/api/storefront/cart"
curl -s -o /dev/null -w "%{http_code}" "http://unknown.bytebite.test/api/storefront/menu"
curl -s -o /dev/null -w "%{http_code}" "http://unknown.bytebite.test/api/storefront/cart"
curl -s -o /dev/null -w "%{http_code}" "http://unknown.bytebite.test/api/storefront/checkout"Expected: Luigi catalog includes Margherita and excludes Carbonara; Mario is the inverse; empty carts are 200 with items: []; unknown host returns 404 for menu, cart, and checkout. A Luigi checkout of Margherita snapshots 850 / 1700. That orderId on Mario returns 404. Body restaurantId does not change the tenant. Luigi accept with etaMinutes: 30 returns accepted; the same orderId under Mario's dashboard path is 404. After accept, Luigi /preparing → /ready → /complete returns completed; Mario's path for those writes is 404. A Luigi logo upload sets Luigi logoUrl only; the same Mario itemId under Luigi's path returns 404 and does not write.
Local — IPv6 hosts check
If requests to .bytebite.test hosts take ~5 seconds on macOS, verify /etc/hosts contains both 127.0.0.1 and ::1 entries. See Local deployment.
Staging — PostgreSQL backup health
Timer and last run
systemctl status bytebite-postgres-backup.timer
systemctl status bytebite-postgres-backup.service
journalctl -u bytebite-postgres-backup.service --since "24 hours ago"Expected:
- Timer is
active (waiting)and enabled - Last service run finished successfully (Type=oneshot shows
inactive (dead)after success — this is normal) - Journal shows dump validation, Restic snapshot saved, local dump cleaned up
Maintenance health
systemctl status bytebite-postgres-backup-maintenance.timer
journalctl -u bytebite-postgres-backup-maintenance.service --since "7 days ago"Expected: retention applied, prune completed, restic check reports no errors.
Full restore validation (manual drill)
sudo /usr/local/lib/bytebite/postgres-restore-drill.shExpected terminal result:
RESTORE DRILL VERIFIEDAfter drill:
- No temporary restore PostgreSQL container remains (
docker ps --filter ancestor=postgres:18) - No temporary restore-drill directory remains under
/var/lib/bytebite-backups/
The live staging database is never modified during the drill.
Related
- Troubleshooting — when tests fail
- Operations — when to run tests
- TLS — certificate details