DEPLOYMENT — production deployment guide (P20/R5a, ADR-037)
Canonical runtime: PHP 8.5.12 EVERYWHERE (stdlib-only; owner directive
2026-10-25 — prod
/opt/php85, CI, Docker and the agent sandbox via
packages.sury.org; the floor is 8.5 and
bin/iya doctorchecks it). Database: SQLite (WAL). TLS:
Caddy 2 automatic HTTPS. Two supported topologies: **(A) docker
compose and (B) bare-metal systemd. A minimal VPS — 1 vCPU /
1 GB RAM / 10 GB disk** — is sufficient for beta-scale traffic; SQLite +
built-in PHP workers keep the moving parts at exactly three processes
(web, worker loop, reverse proxy).
Contents: Requirements · Prod .env · A: docker compose · B: bare-metal · TLS · Backups · Restore runbook · Upgrades · SMTP/DNS mail · Log rotation · Monitoring · Security notes
1. Requirements
| Component | Requirement | Notes |
|---|---|---|
| OS | Any modern Linux (Debian 12+, Ubuntu 22.04+, Alpine for containers) | systemd for option B |
| PHP | 8.5.12 (floor: 8.5) CLI | option B only; the docker image tracks php:8.5-cli-alpine — it bundled 8.5.11 at the last build, refresh at the next image rebuild |
| PHP extensions | pdo_sqlite sqlite3 mbstring intl zip curl dom simplexml opcache | dom+simplexml are required by the L2 contribution runner (ADR-032); opcache for production |
| Reverse proxy / TLS | Caddy 2 (recommended) or nginx + certbot | Caddy gives automatic HTTPS with zero cron |
| Docker (option A) | Engine 24+ with compose v2 | docker compose -f deploy/docker-compose.yml config must pass |
| Disk | 10 GB+ | SQLite + uploads + contrib quarantine + logs; backups live off-box |
| RAM | 1 GB | web (4 CLI workers) + worker loop + Caddy fit comfortably |
| DNS | A/AAAA for apex, www, agent-swarm, and * (tenant subdomains) | wildcard TLS needs DNS-01 — see §5 |
Debian/Ubuntu packages for option B:
sudo apt-get update
sudo apt-get install -y php-cli php-sqlite3 php-mbstring php-xml \
php-curl php-intl php-zip caddy
php -m | grep -Ei 'pdo_sqlite|sqlite3|mbstring|intl|zip|curl|dom|simplexml|Zend OPcache'
Get the code (either topology):
sudo mkdir -p /opt/iya-platform && sudo useradd -r -s /usr/sbin/nologin iya || true
# from a release tarball:
sudo tar xzf iya-platform_<stamp>_<commit>.tar.gz -C /opt/iya-platform --strip-components=1
# …or from git:
# sudo -u iya git clone <your-repo-url> /opt/iya-platform
sudo chown -R iya:iya /opt/iya-platform/var
cd /opt/iya-platform && sudo -u iya php bin/iya doctor
var/ is the ONLY directory that must be writable at runtime (SQLite DB, uploads, contrib quarantine, metrics counters, logs). Everything else can be root-owned and read-only — both systemd units ship ProtectSystem=full + ReadWritePaths=/opt/iya-platform/var.
2. Production .env
Copy the template and edit — the real .env is never committed:
sudo -u iya cp .env.example .env && sudo -u iya chmod 600 .env
sudo -u iya php bin/iya key:generate # -> 64 hex chars for APP_KEY
Non-negotiable production values (drift from dev defaults is deliberate):
| Key | Prod value | Why |
|---|---|---|
APP_ENV | production | disables ?_host= override, tightens error pages |
APP_DEBUG | false (0) | no stack traces to clients |
APP_KEY | generated, unique | session/cookie + derived-secret root |
WEBHOOK_SECRET_ENC_KEY | dedicated 32+ char secret | AES-256-GCM for stored webhook secrets. Dev-derived (empty ⇒ derived from APP_KEY) is FORBIDDEN in prod (ADR-035.7): rotating APP_KEY must not decrypt/brick tenant webhook secrets. Generate: php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' |
CREDENTIAL_SIGNING_KEY | dedicated secret | HS256 agent-credential signatures; same rule — no dev derivation in prod (ADR-036.9). An empty value silently falls back to an APP_KEY-derived key: fine for dev, an incident waiting to happen in prod. |
CONTRIB_BETA_WINDOW | 172800 (48 h) | beta→stable metrics window; dev uses 600 |
MAIL_DRIVER | smtp (+ MAIL_HOST/PORT/USER/PASS/FROM) | log is dev-only; see §9 for SPF/DKIM/DMARC |
TRUSTED_PROXIES | the proxy hop only: 127.0.0.1 (bare-metal Caddy) or 172.30.0.0/24 (compose — fixed subnet) | visitor-IP correctness; wrong value = the proxy IP in analytics (the historical abuseipdb.ru bug) |
COOKIE_SECURE | true | HTTPS-only cookies |
DEV_ALLOW_HOST_OVERRIDE | false | never in prod |
DEV_SEED_DEMO | false | never in prod |
CONTRIB_RUNNER_LEVEL | L2 | production promotion REQUIRES the container runner; CONTRIB_ALLOW_L1_PROMOTE=false |
METRICS_TOKEN | non-empty random | gates private metric rows on /api/v1/metrics (§11) |
SSRF_BLOCK_PRIVATE / WEBHOOK_BLOCK_PRIVATE | true | outbound SSRF guards (only drills disable them) |
AGENT_PUSH_ENABLED | opt-in | signed agent push webhooks; enable after review |
BILLING_ENABLED | false until live Stripe keys exist (S2 checkout + S3 webhook + S4 enforcement code ship dark) | Stripe S1–S4 (ADR-058/058.2/058.3/058.4): dark launch — /dashboard/billing shows the honest free-beta stub, /pricing keeps variant A, POST /billing/webhook and POST /dashboard/billing/portal are 404s (no surface, nothing journaled), plan multipliers/quota resolve to the free baseline with ZERO billing lookups, zero Stripe calls; STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET are secrets (masked in diagnostics), prod values only in this file on the server |
STRIPE_PRICE_PRO | empty until the Stripe dashboard Price exists | Stripe S2 (ADR-058.2): price_... id of the Pro subscription Price; empty + gate on = stripe_not_configured fail-fast (zero external calls), the upgrade CTA degrades to a flash on /pricing |
STRIPE_WEBHOOK_SECRET | empty until the webhook endpoint is registered in the Stripe dashboard | Stripe S3 (ADR-058.3): whsec_... signing secret of POST /billing/webhook; empty + gate on = every delivery fails closed as 503 stripe_not_configured. The route is CSRF-exempt IN CODE (Csrf::SIGNATURE_EXEMPT_PATHS) — do NOT add /billing/ to CSRF_EXEMPT_PATHS; register the endpoint in the Stripe dashboard with the checkout.session.completed, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted events (full runbook: docs/BILLING.md, S5) |
BILLING_GRACE_DAYS | 3 | Stripe S1/S4 (ADR-058.1/058.4): past_due keeps the paid plan this many days after current_period_end (dunning window); the billing dashboard renders an in-window vs expired banner from the same config value |
MAX_LINKS_FREE | 500 | free-plan link quota baseline; Stripe S4 (ADR-058.4) enforces it per user at link creation ONLY with the gate on (pro = ×10 via PlanCatalog) — gate off = no cap, exactly as before S4 |
VISIT_RETENTION_DAYS_PRO | 1825 | P22/R2 (ADR-069): pro-plan visits horizon for the purge worker; applies ONLY with BILLING_ENABLED=true (gate-off = VISIT_RETENTION_DAYS for everyone); clamped with max() against the free horizon — a misconfiguration can never purge MORE |
VISIT_PURGE_DRY_RUN | false | P22/R2 ops rehearsal knob: the visits purge counts + logs what it WOULD delete and deletes nothing |
RUM_ENABLED | false until the privacy review lands (CONSENT_MODE, R7) | js-error beacon (ADR-031/038) |
Contribution pipeline kill switches stay OFF until you mean it (CONTRIB_ENABLED, CONTRIB_RUNNER_ENABLED, CONTRIB_AUTO_PROMOTE=false always — a human confirms promotions).
First boot:
sudo -u iya php bin/iya migrate # idempotent; docker `app` does this on boot
# php bin/iya seed # NEVER in production (demo data)
sudo -u iya php bin/iya doctor
3. Option A — docker compose
Stack (repo files deploy/docker-compose.yml + deploy/Caddyfile, image from the repo-root Dockerfile, base php:8.5-cli-alpine):
- app — migrate-on-boot, serves on
:8080(4 CLI workers), healthcheck
on /api/v1/health;
- worker — same image/volume, `while true; do php bin/iya worker --once;
sleep ${IYA_WORKER_SLEEP:-60}; done; starts only after app` is healthy (boot migration finished);
- caddy — ports 80/443(+udp), automatic HTTPS, reverse-proxies to
app:8080, access log in the caddy-data volume;
- volumes —
iya-var → /app/var(ALL persistent state: SQLite, uploads,
contrib, metrics, logs), caddy-data (certs!), caddy-config.
cd /opt/iya-platform
cp .env.example .env && chmod 600 .env # fill in §2 values; add:
echo 'TRUSTED_PROXIES=172.30.0.0/24' >> .env # fixed compose subnet
docker compose -f deploy/docker-compose.yml config --quiet # sanity
docker compose -f deploy/docker-compose.yml up -d --build
curl -sf -H 'Host: abuseipdb.ru' http://127.0.0.1/api/v1/health && echo OK
Operational notes:
- certs live in
caddy-data— back it up or lose the rate-limit budget
of Let's Encrypt (`docker run --rm -v iya-platform_caddy-data:/d -v $(pwd)/backups:/b alpine tar czf /b/caddy-data.tgz -C /d .`).
- The compose network subnet is FIXED (
172.30.0.0/24) soTRUSTED_PROXIES
is deterministic; app pins to 172.30.0.10.
- Rebuild after upgrades: `docker compose -f deploy/docker-compose.yml up -d
--build` (app re-runs migrations on boot; worker waits for health).
- SQLite + docker volume: keep the DB on a LOCAL volume (never NFS — WAL
needs real POSIX locks).
- Backups: run
deploy/backup.shfrom a cron on the HOST with
IYA_BACKUP_DIR pointing at a host dir mounted into the app container, or docker compose exec app php bin/iya …-style snapshot (§6 adapts: the tarball contents are identical — var/ lives in the iya-var volume).
4. Option B — bare-metal systemd
Units ship in deploy/ (conventions: app root /opt/iya-platform, run user iya, secrets in /opt/iya-platform/.env — adjust the unit headers if your layout differs; they carry no developer-home paths):
| Unit | Runs | Port |
|---|---|---|
iya-web.service | php bin/iya serve --host=127.0.0.1 --port=8080 (built-in server, PHP_CLI_SERVER_WORKERS=4, router scripts/dev-router.php = static fast-path + front controller) | loopback 8080 |
iya-worker.service | while true; do php bin/iya worker --once; sleep ${IYA_WORKER_SLEEP:-60}; done | — |
sudo install -m 644 deploy/iya-web.service /etc/systemd/system/
sudo install -m 644 deploy/iya-worker.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now iya-web iya-worker
systemctl status iya-web --no-pager | head -5
curl -sf -H 'Host: abuseipdb.ru' http://127.0.0.1:8080/api/v1/health && echo OK
Caddy reverse proxy (bare metal) — install deploy/Caddyfile to /etc/caddy/Caddyfile, replacing the upstream app:8080 with 127.0.0.1:8080:
sudo sed 's/app:8080/127.0.0.1:8080/' deploy/Caddyfile | sudo tee /etc/caddy/Caddyfile
sudo systemctl reload caddy
Set TRUSTED_PROXIES=127.0.0.1 in .env so the app reads Caddy's X-Forwarded-For (and only Caddy's).
Cron alternative (no systemd — e.g. container-less minimal hosting):
# worker pass every minute
* * * * * cd /opt/iya-platform && /usr/bin/php bin/iya worker --once >> var/logs/cron-worker.log 2>&1
# nightly backup at 03:17 UTC (§6)
17 3 * * * IYA_BACKUP_DIR=/var/backups/iya IYA_OFFSITE_DIR=/mnt/offsite/iya /opt/iya-platform/deploy/backup.sh >> /var/log/iya-backup.log 2>&1
The web tier under cron-less setups still needs a supervised long-runner: php bin/iya serve … under runit/s6, or @reboot + a keepalive cron line (`pgrep -f 'bin/iya serve' >/dev/null || (cd /opt/iya-platform && nohup php bin/iya serve --host=127.0.0.1 --port=8080 >> var/logs/serve.log 2>&1 &)`). systemd is the supported path; cron-only is best-effort.
5. TLS — Caddy
Caddy obtains and renews Let's Encrypt certificates automatically for every site address in the Caddyfile ({$IYA_APEX:abuseipdb.ru}, www., agent-swarm.) — HTTP-01 needs ports 80/443 open and DNS pointing at the box. Set ACME_EMAIL (compose passes it from .env) for expiry notices.
Tenant wildcard subdomains (*.abuseipdb.ru → per-user sites) require a wildcard certificate, and Let's Encrypt issues those only via DNS-01:
- build Caddy with your DNS provider plugin:
xcaddy build --with github.com/caddy-dns/cloudflare (or route53/gandi/…);
- swap the image in
deploy/docker-compose.yml(caddy:→ your build); - uncomment the
*.{$IYA_APEX:abuseipdb.ru}block indeploy/Caddyfileand
export the provider token (CF_API_TOKEN etc.) to the caddy service.
Alternative: buy a commercial *.abuseipdb.ru certificate, terminate it on an upstream LB, and run Caddy (or the app) plain-HTTP behind it — then set TRUSTED_PROXIES to that LB and keep COOKIE_SECURE=true.
Renewals need no cron (Caddy's background ACME manager); verify with caddy fmt --overwrite /etc/caddy/Caddyfile && systemctl reload caddy after edits and watch /data/iya-access.log + the caddy journal.
6. Backups
deploy/backup.sh (crontab-ready, set -euo pipefail) produces a versioned tarball iya-platform_<UTCstamp>_<commit>.tar.gz + .sha256 sidecar with:
- the SQLite snapshot taken via
VACUUM INTO(online, WAL-safe,
integrity-checked with PRAGMA integrity_check before packaging — no sqlite3 CLI needed, just PHP);
- the whole
var/tree exceptcache/+test/scratch (uploads, contrib
quarantine, metrics counters, logs);
.envasenv.snapshot(tarball ischmod 600— it carries secrets);- a
VERSIONstamp (git commit orvar/RELEASE, platform semver, PHP).
Environment knobs: IYA_BACKUP_DIR (default /var/backups/iya), IYA_OFFSITE_DIR (offsite mirror — a mounted NAS/S3-fuse/another disk), IYA_BACKUP_KEEP (local generations to keep, default 14).
Crontab (also printed in the script header):
17 3 * * * IYA_BACKUP_DIR=/var/backups/iya IYA_OFFSITE_DIR=/mnt/offsite/iya /opt/iya-platform/deploy/backup.sh >> /var/log/iya-backup.log 2>&1
Rotation rule (project trap #33, enforced by the script): an old local tarball is deleted ONLY after its sha256 was verified against the offsite copy (or, without offsite, against its own sidecar). Unverified files are kept and reported on stderr. Offsite copies are verified right after upload (sha256sum -c). Do not "clean up" backup dirs by hand.
Docker topology: run the same script on the host against a bind-mounted var/ (or mount iya-var read-only into a one-shot container); the archive layout is identical, so restore.sh works for both.
7. Restore runbook
deploy/restore.sh <tarball> [target-app-dir] is deliberately conservative — it verifies the sidecar sha256, then stages:
env.snapshot → <target>/.env.restore(live.envis NEVER overwritten;
diff and merge by hand, and rotate secrets if the archive came from a compromised box);
iya.sqlite3 → <target>/var/data/iya.sqlite3.restore(live DB is NEVER
overwritten; promotion is an explicit mv printed by the script);
- the rest of
var/merged file-by-file (no deletions).
Full bare-metal restore on a fresh VPS:
- provision §1 (packages,
iyauser,/opt/iya-platformfrom the release
tarball matching VERSION/commit in the backup — or any newer one: migrations move the schema forward, never back);
- `deploy/restore.sh /path/iya-platform_<stamp>_<commit>.tar.gz
/opt/iya-platform`;
- merge
.env.restore→.env(chmod 600); - promote the DB: `mv var/data/iya.sqlite3{,.fresh} && mv
var/data/iya.sqlite3{.restore,}`;
php bin/iya migrate && php bin/iya doctor;systemctl start iya-web iya-worker(docker:up -d);- verify
curl -sf -H 'Host: abuseipdb.ru' http://127.0.0.1:8080/api/v1/health,
log in, spot-check a tenant redirect and /status;
- Caddy re-issues certs automatically; if you saved
caddy-data, restore
it to skip issuance entirely.
Drill it: a restore you never rehearsed is a hypothesis.
8. Upgrade procedure
Zero-downtime-ish single-box upgrade (order matters — migrate BEFORE swap):
cd /opt/iya-platform
# 0) backup first (§6) — every upgrade starts with a fresh tarball
deploy/backup.sh
# 1) fetch the new release NEXT TO the live root
sudo -u iya tar xzf iya-platform_<new>.tar.gz -C /opt/iya-platform.new --strip-components=1
# 2) carry over state + secrets (var/ and .env live outside the code tree)
sudo -u iya cp -a var /opt/iya-platform.new/var.live # or keep var/ as a bind-mount/symlink shared by both roots
sudo -u iya cp .env /opt/iya-platform.new/.env && sudo chmod 600 /opt/iya-platform.new/.env
# 3) gates on the NEW tree against the LIVE database copy:
cd /opt/iya-platform.new && sudo -u iya php bin/iya lint | tail -1
sudo -u iya php tests/run.php | tail -1 # optional but cheap insurance
sudo -u iya php bin/iya migrate # applies pending migrations
# 4) swap + restart
sudo systemctl stop iya-web iya-worker
sudo mv /opt/iya-platform /opt/iya-platform.old && sudo mv /opt/iya-platform.new /opt/iya-platform
sudo systemctl start iya-web iya-worker
curl -sf -H 'Host: abuseipdb.ru' http://127.0.0.1:8080/api/v1/health
# 5) watch /status + metrics for CONTRIB_BETA_WINDOW; rollback = swap back
# (.old still there) and restore the pre-upgrade DB from step 0 tarball.
Docker: docker compose -f deploy/docker-compose.yml up -d --build — the app container migrates on boot, the worker waits for health. Rollback = docker tag the previous image back to iya-platform:latest + up -d, then restore the DB from the step-0 tarball if migrations were destructive (they are designed not to be).
Never skip: backup → lint/tests → migrate → swap. Migrations are forward-only; the rollback path is always "previous tarball".
9. SMTP + SPF/DKIM/DMARC
Mail leaves the platform via MAIL_DRIVER=smtp (drivers: log (dev), smtp, sendmail). Prod config: MAIL_HOST, MAIL_PORT=587, MAIL_ENCRYPTION=tls, MAIL_USER, MAIL_PASS, MAIL_FROM=no-reply@abuseipdb.ru, MAIL_FROM_NAME=abuseipdb.ru. Test with a password-reset on a real mailbox and watch var/logs/app-<day>.log.
Full reference: [EMAIL.md](EMAIL.md) — every MAIL_* key, DKIM key generation and rotation (selector iya1, rsa2048), the DMARC rollout ladder, the deliverability checklist, the php bin/iya mail:test / doctor verification runbook and the live-SMTP drill.
DNS checklist for deliverability (all on the sending domain, e.g. abuseipdb.ru):
| Record | Value (example) | Purpose |
|---|---|---|
| SPF (TXT) | v=spf1 mx include:<smtp-provider> ~all | authorizes the sending hosts; exactly ONE SPF record per domain |
| DKIM (TXT) | <selector>._domainkey IN TXT "v=DKIM1; k=rsa; p=<pubkey>" | provider-generated selector (mail relay signs); verify with dig TXT <selector>._domainkey.abuseipdb.ru |
| DMARC (TXT) | _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@abuseipdb.ru; adkim=s; aspf=s" | start p=none for a week of reports, then quarantine → reject |
| MX | provider's MX | needed if the domain also RECEIVES (replies to mail@) |
| rDNS/PTR | provider-managed for its IPs | self-hosted postfix without proper PTR is a dead end — use a relay (SES/Postmark/MS365) |
Post-setup validation: send to a mailbox you control, check Authentication-Results headers (spf=pass dkim=pass dmarc=pass), and register the domain at Google Postmaster Tools / Microsoft SNDS. Bounce handling: start with the rua= report address, automate later.
10. Log rotation
The app writes DATED files under var/logs/ (app-<day>.log, access-<day>.log, js-errors-<day>.log (RUM, when enabled), cron-worker.log from §4) plus daily var/metrics/counter-<day>.json counters. Two layers:
- app-side: dated naming means no giant single file; a weekly cron
prunes anything older than the retention you choose:
30 3 * * 0 find /opt/iya-platform/var/logs -name '*.log' -mtime +30 -delete; \
find /opt/iya-platform/var/metrics -name 'counter-*.json' -mtime +400 -delete
(keep metrics ~13 months: the /status uptime ring reads 90 days, beta verdicts compare windows.)
- system-side (journals + Caddy): stock logrotate/journald limits —
SystemMaxUse=200M in /etc/systemd/journald.conf; Caddy's own file log rolls at 10 MB × 5 (set in deploy/Caddyfile).
Disk-pressure smoke check: du -sh /opt/iya-platform/var/* monthly; the contrib quarantine (var/contrib/) has its own retention (CONTRIB_QUARANTINE_DAYS=7).
11. Monitoring
GET /api/v1/health— liveness (used by compose healthcheck + Caddy).GET /api/v1/metrics— Prometheus text exposition (R5b): public minimum
(iya_up, iya_info, queue depths) + PRIVATE rows (per-zone request counters, rate-limit hits, contrib pipeline counters, uptime, RUM js_errors) gated by METRICS_TOKEN — send Authorization: Bearer <METRICS_TOKEN>. Scrape every 15–60 s.
GET /status— public status page: 90-day uptime ring + incident history
(admin CRUD at /admin/incidents, R5b).
- Worker liveness:
systemctl is-active iya-worker/ container health; the
visit queue depth (iya_queue_depth in metrics) growing steadily means the worker loop is stuck.
- Alerts: the platform has an internal alert pipeline (
alert_events,
webhook link.target_down etc.) — wire WEBHOOK_ENABLED=true receivers to your ops channel.
12. Security notes
- Run as the unprivileged
iyauser; units ship hardened
(NoNewPrivileges, ProtectSystem=full, ProtectHome, PrivateTmp).
.envis mode 600 and inside the app root — never serve it (the front
controller only maps public/); backups carry it as env.snapshot, so treat backup storage as SECRET storage (offsite dir: encrypt at rest or restrict mounts).
- Dedicated
WEBHOOK_SECRET_ENC_KEY+CREDENTIAL_SIGNING_KEY(§2) —
dev-derived keys in prod are forbidden (ADR-035.7/ADR-036.9).
- Keep
ADMIN_REQUIRE_2FA=true; scopeADMIN_IP_ALLOWLISTif admins have
static IPs.
- Firewall: only 80/443 (+SSH from your management range) are public; the
app listens on loopback (bare-metal) or the compose network (docker).
- Updates: OS security patches via unattended-upgrades; PHP minor bumps are
safe (floor 8.5, canonical 8.5.12 everywhere); re-run §8 for every app release.
13. Continuity endpoint (qwen-gateway) + prod mail state — s30 notes
qwen-gateway. deploy/qwen-access/qwen-gateway.php is a self-contained stdlib endpoint for agent continuity (workspace mirror over qwen_access/, multi-transport: HTTP JSON/GET/raw-stream/multipart, MCP JSON-RPC, CLI stdin/argv, raw socket --serve). Install it into the site root (/var/www/iya/data/www/abuseipdb.ru/qwen-gateway.php) — NOT inside qwen_access/ (that zone is download-only by design and stays the gateway's data store). Full owner checklist (token rotation via SetEnv QWEN_TOKEN, optional QWEN_ALLOW_CIDR, nginx advice, smoke test) and the agent runbook: docs/QWEN-ACCESS.md. The app itself does not depend on the gateway; not installing it breaks nothing. The qwen_access/ store zone is served world-readable with directory listings ON (semi-public by design, docs/QWEN-ACCESS.md §8.2): the live exposure probe and the owner checklist for narrowing or closing it (options A/B/C + apply-and-verify + rollback) are docs/QWEN-ACCESS.md §8.3 — an owner decision; the gateway reads the store from the filesystem, so deny all breaks nothing.
Ops dashboard + continuation worker (s45, gateway v1.1.0). The gateway file doubles as: (1) the owner ops dashboard — https://abuseipdb.ru/qwen-gateway.php?op=dash (md5 password gate per owner spec; panels: prod sha/age, services, cert days, disk, alerts, worker heartbeat, #101 residue, deploy history, agent notes); (2) the hourly continuation worker — root cron php …/qwen-gateway.php --worker (install: --worker-install; control: store flag deploy/worker-disabled; output: _audit/status-latest.json + status-history.ndjson + alerts.ndjson; bundle: deploy/qwen-access/WORKER.md); (3) agent context-economy ops status/probe/batch. Details: QWEN-ACCESS §4.1+§9.
Prod mail (STATUS s44: CUTOVER DONE). The live .env on abuseipdb.ru carries MAIL_DRIVER=smtp — the owner executed the switch on 2026-10-24 (s41): local Exim relay MAIL_HOST=127.0.0.1 port 25, dry-run + live send OK, systemctl reload php-fpm85 performed (Config caches .env per worker process — the reload is MANDATORY), and the setting survived self-deploys #12/#13/#14. Waitlist/notification mail (P21/T1 contour) is now really delivered; the remaining R1 step is the owner acceptance on a real external mailbox. Verification marker: php bin/iya mail:test --to=<address> (expects the MAIL-TEST OK marker). SPF/DKIM/DMARC prerequisites: §9 and docs/EMAIL.md. The exact switch procedure on the live box (P22/R1) — including the worker liveness check, the honest failure semantics of the synchronous guest paths and the rollback — is docs/EMAIL.md §11.
Migration 022 reconciliation (one-time). If php bin/iya migrate on the already-migrated prod DB aborts with a 022 checksum mismatch after deploying the repo tree (the repo's schema-parity reconstruction vs the bytes the hotfix applied), follow docs/QWEN-ACCESS.md §7 — compare PRAGMA table_info(agent_owner_binds), then either reconcile the schema_migrations checksum or adopt the prod bytes into the repo.
14. SSE streams in production (P21/T3)
GET /api/v1/agents/me/stream (and the dashboard realtime streams) hold one PHP worker for the whole connection window. Production checklist:
- php-fpm:
request_terminate_timeoutmust exceed the stream window
(SSE_WINDOW_SEC, hard-capped by SSE_MAX_SECONDS=300), or the worker is killed mid-stream and the client sees a truncated response instead of the polite event: end. Size the pool accordingly: every concurrent stream parks one worker for up to the window (pm.max_children >= expected concurrent streams + normal traffic).
- nginx / reverse proxy: the response already sends
X-Accel-Buffering: no and Cache-Control: no-store, no-transform; additionally set proxy_read_timeout >= the window (default 60 s is fine for the default 30 s window) and keep proxy_buffering off for the /api/v1/agents/me/stream location if your build ignores X-Accel-Buffering.
- Kill switch:
AGENT_SSE_ENABLED=false=> honest403 sse_disabled
(agents fall back to polling). SSE_MAX_CONN caps concurrent streams per token (slot lock files in var/run/sse/; flock dies with the worker — no stale locks after crashes, the directory needs write access for the web user).
- Dev router:
php -Sis single-threaded by default — a stream blocks
every other request until the window closes. For local multi-client testing start it with PHP_CLI_SERVER_WORKERS=4.
15. Billing (Stripe S1–S5) in production
The billing contour ships DARK (BILLING_ENABLED=false): zero Stripe calls, /pricing stays variant A, upgrade/success/cancel/webhook/portal routes are 404s. Turning it on is an owner-side procedure with a rehearsal requirement — the full runbook (Product/Price creation, webhook endpoint registration with the exact 4-event v1 set, customer-portal configuration, secret rotation, go-live checklist, stripe trigger verification, test cards) lives in docs/BILLING.md. Deploy-side summary:
- Env keys (server
.envonly — the deploy hook's rsync preserves
.env/var/): BILLING_ENABLED, STRIPE_SECRET_KEY (secret), STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET (secret), STRIPE_API_BASE (keep default), STRIPE_PRICE_PRO, BILLING_GRACE_DAYS=3, MAX_LINKS_FREE=500. Secrets are masked in php bin/iya doctor output — verify after every .env edit.
- Migrations:
023_billing(tablesstripe_subscriptions,
billing_events) — applied automatically by the deploy hook's bin/iya migrate; nothing to do manually.
- Webhook URL:
https://abuseipdb.ru/billing/webhook— registered in the
Stripe dashboard with the signing secret copied into STRIPE_WEBHOOK_SECRET. The route's CSRF exemption is IN CODE (Csrf::SIGNATURE_EXEMPT_PATHS), never via env.
- Egress: php-fpm needs outbound HTTPS to
api.stripe.com(443) for
checkout/portal session creation; inbound: Stripe must reach /billing/webhook (no allowlisting of Stripe IPs is configured — the HMAC signature is the authenticator).
- Restart: after any billing
.envchange, restartphp-fpm85(the
deploy hook does this; manual flips must too).
- Verification after go-live flip:
docs/BILLING.md§7 steps 6–8
(surface checks + first test payment + 48 h monitoring of billing_events/audit_log).
- Honesty note: the live Stripe API was never exercised from the
sandbox (no internet) — drills prove the contract against a fake sink; the §7 rehearsal in TEST mode is mandatory before live keys.
_Last updated: 2026-10-23 (s38 Stripe S5 billing runbook). Companion files: deploy/ (compose, Caddyfile, systemd units, backup/restore, qwen-access/), .github/workflows/ci.yml (deploy-config job), scripts/_deploy_check.py (sandbox drill), docs/QWEN-ACCESS.md (continuity endpoint), docs/BILLING.md (billing runbook)._
Версия 2.2.0 · AbuseIPDB.RU · webmaster@abuseipdb.ru · source: docs/DEPLOYMENT.md