Skip to main content

Runbook: deploy the public stand (single VPS)

Target: one VPS with 4–8 GB RAM exposing the public edge (api / admin / dashboard domains) via deploy/docker-compose.prod.yml. Time: ~30 minutes. Related: docs/runbooks/production.md (day-2 ops), docs/runbooks/alerts.md (alert delivery), docs/runbooks/backup-restore-rehearsal.md (DR drill).

0. Prerequisites​

  • VPS 4–8 GB RAM, 2+ vCPU, 40 GB disk, Docker Engine + docker compose plugin. The real-models profile (classifier/embedder/llama-server) needs the larger end of that range; without the profile the stack runs the mock tier (see deploy/docker-compose.e2e.yml).
  • DNS: three A records → VPS IP:
    • API_DOMAIN (gateway, OpenAI-compatible API)
    • ADMIN_DOMAIN (admin console)
    • DASHBOARD_DOMAIN (React SPA)
  • Ports 80/443 open (Caddy issues Let's Encrypt certificates on first start).
  • The repo checked out at /opt/yabanin (adjust paths below if different).

1. Secrets (one-time, idempotent)​

cd /opt/yabanin
./scripts/deploy/init-secrets.sh

Creates deploy/.env (from deploy/.env.example) and deploy/secrets/ (pg_uri, session_key, cache_hmac, kms_local_key, metrics-bearer.key), all mode 600. Both paths are gitignored — never commit or copy them off-box unencrypted.

2. Configure deploy/.env​

vi deploy/.env

Required:

VariableMeaning
API_DOMAIN / ADMIN_DOMAIN / DASHBOARD_DOMAINpublic hostnames (Caddy + admin cookie domain)
POSTGRES_PASSWORDgenerated by init-secrets; do not change after the first up (pg_data keeps the old one)

Recommended before exposing:

VariableMeaning
ALERT_WEBHOOK_URLowner webhook (Slack/PagerDuty/ops)
ALERT_TG_BOT_TOKENowner Telegram bot token (from @BotFather)
ALERT_TG_CHAT_IDowner chat id (write to the bot once, then check https://api.telegram.org/bot<token>/getUpdates)

Critical Prometheus alerts fan out to both channels; unset ones stay on the dev black-hole sinks. Test-fire: docs/runbooks/alerts.md.

3. Build and start​

# Dashboard static assets (dashboard-web mounts web/dashboard/dist)
npm ci --prefix web/dashboard && npm run build --prefix web/dashboard

cd deploy
docker compose -f docker-compose.prod.yml build # gateway/admin/loggerd images
docker compose -f docker-compose.prod.yml run --rm migrate # exit code enforced
docker compose -f docker-compose.prod.yml up -d --wait --scale migrate=0

up also starts postgres/redis, the observability stack (prometheus, alertmanager, grafana), caddy (TLS), dashboard-web, the backup and status-page sidecars.

4. Seed (org + root admin + first API key)​

Add to deploy/.env (all optional — unset SEED_ADMIN_EMAIL = no-op):

SEED_ADMIN_EMAIL=owner@example.com
YABANIN_ADMIN_PASSWORD_FILE=/run/secrets/seed_admin_password
SEED_PROFILE=provider # or mock
SEED_UPSTREAM_URL=https://api.z.ai/v1
SEED_UPSTREAM_KEY_FILE=/run/secrets/upstream_key
SEED_MODEL=gpt-4o

Mount the two extra secret files and run the one-shot:

cd deploy
echo ',strong-password' > secrets/seed_admin_password # mode 600, then delete after seeding
echo '$UPSTREAM_KEY' > secrets/upstream_key # mode 600, then delete after seeding
docker compose -f docker-compose.prod.yml run --rm \
-v ./secrets/seed_admin_password:/run/secrets/seed_admin_password:ro \
-v ./secrets/upstream_key:/run/secrets/upstream_key:ro seed

The seeded API key plaintext is printed once to the container log (docker compose logs seed). A second run is an idempotent no-op and does NOT reprint it.

5. Smoke​

# Static + functional smoke against the local stack (mock overlay):
./scripts/deploy/smoke.sh --up

# Public edge checks (from any machine):
curl -sS https://$API_DOMAIN/healthz # {"status":"ok"}
curl -sS https://$ADMIN_DOMAIN/health # {"status":"ok"}
curl -sSI https://$DASHBOARD_DOMAIN/ | head -1 # HTTP/2 200
curl -sS https://$API_DOMAIN/status.html # static status page

Chat completion (real key from the seed step):

curl -sS https://$API_DOMAIN/v1/chat/completions \
-H "Authorization: Bearer $SEED_KEY" -H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'

Rate-limit verification (429)​

Rate limiting is enforced in the app (see the note in deploy/caddy/Caddyfile.tmpl); verify the strictest zone — admin login, 5 attempts / 15 min / IP:

for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "attempt $i: %{http_code}\n" -X POST \
https://$ADMIN_DOMAIN/auth/login -d 'email=nobody@example.com&password=x'
done
# expect 200 (or 401 wrong-credentials) for 1–5 and 429 on attempt 6

The gateway zone (50 req/s/IP, burst 100, Retry-After on 429) needs a load tool to trip: hey -n 300 -c 50 https://$API_DOMAIN/healthz.

6. Post-deploy checklist​

  • All three domains serve TLS with valid certificates.
  • docker compose -f docker-compose.prod.yml ps — every service healthy (migrate/seed exited 0 are expected one-shots).
  • Admin console login works; seeded org and key visible.
  • Telegram/webhook test alert received (see docs/runbooks/alerts.md).
  • https://$API_DOMAIN/status.html shows all services "ok".
  • Backups landing: ls deploy/../ → see docs/runbooks/backup-restore-rehearsal.md; schedule the quarterly restore rehearsal.
  • Remove any temporary secret files created for seeding.