ByteBite Docs
Infrastructure

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_USER and STAGING_BASIC_AUTH_PASSWORD in 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 -dates

Wildcard 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 subjectAltName

Expected: 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 -5

Expected: 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 -8

Expected 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:

CheckExpected
DNS88.99.0.118
TLSValid wildcard cert
HTTPRedirect to HTTPS
Unauthenticated HTTPSHTTP 401, Basic realm ByteBite Staging
StorefrontHTTP 200, "Restaurant not found" (known application gap)
/api/healthHTTP 200, {"status":"ok",...}
/api/storefront/restaurantHTTP 404
/api/storefront/menuHTTP 404
/api/storefront/cartHTTP 404
/api/storefront/checkoutHTTP 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
done

Staging — 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 robots

Expected: 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.sh

Expected terminal result:

RESTORE DRILL VERIFIED

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

On this page