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 composeplugin. Thereal-modelsprofile (classifier/embedder/llama-server) needs the larger end of that range; without the profile the stack runs the mock tier (seedeploy/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:
| Variable | Meaning |
|---|---|
API_DOMAIN / ADMIN_DOMAIN / DASHBOARD_DOMAIN | public hostnames (Caddy + admin cookie domain) |
POSTGRES_PASSWORD | generated by init-secrets; do not change after the first up (pg_data keeps the old one) |
Recommended before exposing:
| Variable | Meaning |
|---|---|
ALERT_WEBHOOK_URL | owner webhook (Slack/PagerDuty/ops) |
ALERT_TG_BOT_TOKEN | owner Telegram bot token (from @BotFather) |
ALERT_TG_CHAT_ID | owner 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/seedexited 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.htmlshows all services "ok". - Backups landing:
ls deploy/../→ seedocs/runbooks/backup-restore-rehearsal.md; schedule the quarterly restore rehearsal. - Remove any temporary secret files created for seeding.