Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
# Asset CDN Operations Runbook
When A7/B2/#51 is reactivated, front the boilerplate's static assets
(`/assets/*`, `/fonts/*`, `/images/*`) with a CDN so the origin no longer pays
the bandwidth + TLS handshake cost for every visitor and so users far from the
Frankfurt DC see sub-second first paints.
**Default provider:** Bunny.net (EU-based, cheap, simple).
**Documented alternative:** Cloudflare.
Related task: [`docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md`](../improvement-plan/03-scaling-infrastructure/asset-cdn.md).
**Rollout status:** CDN provisioning and `ASSET_CDN_URL` flips remain parked
with A7/B2/#51. The deploy workflow already includes a no-op-safe Bunny purge
step for future activation; keep this runbook as the reactivation guide until
`docs/improvement-plan/OPERATOR-CHECKLIST.md` and
`docs/improvement-plan/PROGRESS.md` move #51 out of backlog.
## Architecture
```
Browser
│
├─→ cdn.{domain}/assets/app-3f4a1b.js ──→ Bunny edge (cache hit: ~1 ms)
│ │
│ └─ origin pull on miss:
│ app.{domain}/assets/app-3f4a1b.js
│
└─→ app.{domain}/ (HTML, API, /healthz) ──→ origin server (no CDN detour)
```
Only static directories travel through the CDN. HTML, API responses, and
`/healthz`/`/readyz` go direct to origin so authenticated content and
liveness probes are never edge-cached.
## Cache strategy
| Path pattern | `Cache-Control` | Why |
| ----------------------------- | ------------------------------------------ | --------------------------------------------------------------------- |
| `/assets/*` (Vite-hashed) | `public, max-age=31536000, immutable` | Filenames embed a content hash — safe to cache forever. |
| `/fonts/*`, `/images/*` | `public, max-age=86400` | Static brand assets without hash; 1-day TTL is the longest safe value.|
| HTML documents (loader/SSR) | `no-store` (unless loader overrides) | May contain CSRF tokens, session-derived markup, or feature flags. |
| `/api/*`, `/healthz`, `/readyz` | bypass cache (edge rule) | Authenticated and/or liveness-critical — caching corrupts behaviour. |
The headers above are set by `server/server.ts` (`getStaticHeaders`) for the
static directories and by `apps/web/app/entry.server.tsx` for the SSR HTML
response. Loaders may set their own `Cache-Control` to opt out of `no-store`
(e.g. marketing landing pages choosing `public, max-age=300`).
## Provider comparison
| Concern | Bunny.net (default) | Cloudflare (alternative) |
| --------------------- | --------------------------------------- | ------------------------------------------ |
| HQ | Slovenia (EU) | United States |
| Pricing baseline | ~€1-5/month for small SaaS | Free tier sufficient for small sites |
| DSGVO posture | EU jurisdiction, simple DPA | Requires Schrems-II analysis for EU users |
| DNS migration | Not required — single CNAME suffices | Free tier requires full DNS migration |
| Image optimisation | Bunny Optimizer add-on (~$9.50/mo) | Cloudflare Images ($5/mo + per-image fee) |
| Cache purge API | Simple POST with `AccessKey` header | Token-scoped API; per-zone purge endpoints |
| Origin-shield option | Yes (single optional PoP in front of origin) | Yes (Argo Tunnel / Origin Rules) |
**Choose Bunny.net unless** the operator already runs all DNS through
Cloudflare and wants a single console — then Cloudflare is the lower-friction
choice.
## Bunny.net setup (default, when A7/B2/#51 is reactivated)
Do not execute these steps while A7/B2/#51 remains parked. They are the
reactivation checklist for the future CDN rollout. None of this is automated by
the deploy pipeline.
1. **Create the Bunny account.**
Sign up at <https://bunny.net>, add a payment method, generate an
*account-level API key* in *Account → API*. Store it as the `BUNNY_API_KEY`
secret in the Gitea / Coolify environment.
2. **Create a pull zone per environment.**
- Staging pull zone:
- Name: `cdn-staging-{project}`
- Origin URL: `https://staging.{domain}` (the app origin)
- Pricing tier: *Standard Network* (high volume HDD/Volume can be selected later)
- Production pull zone:
- Name: `cdn-{project}`
- Origin URL: `https://app.{domain}`
Note the numeric **pull-zone ID** from the URL — store it as
`BUNNY_PULL_ZONE_ID` (one per environment).
3. **Add a custom hostname.**
In the pull zone, *Hostnames → Add Custom Hostname*:
- Staging: `cdn-staging.{domain}`
- Production: `cdn.{domain}`
Create the matching DNS records at your DNS provider:
```
cdn-staging.{domain} CNAME cdn-staging-{project}.b-cdn.net
cdn.{domain} CNAME cdn-{project}.b-cdn.net
```
Wait until the records resolve (`dig cdn-staging.{domain}`), then enable
*Force SSL* — Bunny provisions a Let's Encrypt certificate automatically
(~5 minutes).
4. **Configure edge rules.**
- **Bypass cache for `/api/*`** (matching: URL path starts with `/api/`).
- **Bypass cache for `/healthz`, `/readyz`** (URL exact match).
- **Force cache for `/assets/*`** (URL path starts with `/assets/`,
`Override Cache Time = 1 year`) — defensive, since the origin already
emits `immutable`, but protects against future regressions.
5. **Enable origin shield (recommended).**
*Configuration → Origin Shield → Enabled* with the closest PoP to the
origin DC (Frankfurt for Hetzner FRA). Reduces origin load when the edge
cold-misses.
6. **Set the runtime env vars in Coolify.**
| Env var | Where to set | Example |
| --------------------- | ------------------------------------- | ------------------------------------ |
| `ASSET_CDN_URL` | Web app (build-time + runtime) | `https://cdn-staging.{domain}` |
| `BUNNY_PULL_ZONE_ID` | Gitea secret + Coolify env (deploy) | `<pull-zone-id>` |
| `BUNNY_API_KEY` | Gitea secret + Coolify env (deploy) | `<bunny-api-key>` |
`ASSET_CDN_URL` must be present at **build time** — Vite bakes the CDN
origin into asset URLs via `apps/web/vite.config.ts` (`resolveAssetBase`).
7. **Trigger a deploy** and run the smoke tests below.
## Smoke tests (when A7/B2/#51 is reactivated)
Do not run these as active acceptance checks while CDN rollout remains parked.
```bash
# 1. Asset is served via Bunny
curl -sI https://cdn-staging.{domain}/assets/<hash>.js | head -10
# Expect: HTTP/2 200
# server: BunnyCDN-DE1 (or whichever PoP)
# cache-control: public, max-age=31536000, immutable
# x-cache: HIT (after the first request warms it)
# 2. HTML is NOT cached by Bunny
curl -sI https://staging.{domain}/ | head -10
# Expect: cache-control: no-store
# no `server: BunnyCDN` header
# 3. /fonts and /images get the 1-day TTL
curl -sI https://cdn-staging.{domain}/fonts/inter.woff2 | grep cache-control
# Expect: cache-control: public, max-age=86400
# 4. Purge API is reachable
curl -sX POST "https://api.bunny.net/pullzone/${BUNNY_PULL_ZONE_ID}/purgeCache" \
-H "AccessKey: ${BUNNY_API_KEY}" -o /dev/null -w "%{http_code}\n"
# Expect: 204
```
## Cache-purge script
`scripts/cdn/bunny-purge.ts` wraps the Bunny purge API and is wired into
the `Deploy` workflow as the **Purge asset CDN** step (post-deploy). It
runs unconditionally — if `BUNNY_API_KEY` or `BUNNY_PULL_ZONE_ID` is unset
it logs and exits 0, so the step is safe before the operator finishes
the setup above.
```bash
# Manual whole-zone purge
BUNNY_PULL_ZONE_ID=<pull-zone-id> BUNNY_API_KEY=<bunny-api-key> \
pnpm exec tsx scripts/cdn/bunny-purge.ts
# Targeted purge (single asset)
BUNNY_PULL_ZONE_ID=<pull-zone-id> BUNNY_API_KEY=<bunny-api-key> \
ASSET_CDN_URL=https://cdn.{domain} \
pnpm exec tsx scripts/cdn/bunny-purge.ts --paths /assets/app-3f4a1b.js
# Multiple paths (comma-separated or repeated --paths)
pnpm exec tsx scripts/cdn/bunny-purge.ts --paths /assets/a.js,/assets/b.js
# Dry-run (prints the plan, makes no API call)
pnpm exec tsx scripts/cdn/bunny-purge.ts --dry-run
```
Exit codes:
| Code | Meaning |
| ---- | -------------------------------------------------------------------- |
| `0` | Purge succeeded, or skipped because credentials were intentionally absent. |
| `1` | Bunny returned a non-2xx response, or invalid CLI arguments. |
## Cloudflare setup (alternative)
Only follow this section if you decided against Bunny.net in the table above.
1. Add the apex/subdomain to Cloudflare (free plan suffices for small sites).
Either migrate DNS fully or proxy only the `cdn.{domain}` CNAME.
2. Create a *Page Rule* for `cdn.{domain}/assets/*` with:
*Cache Level → Cache Everything*, *Edge Cache TTL → 1 month*.
3. Create a *Page Rule* for `cdn.{domain}/api/*` with:
*Cache Level → Bypass*.
4. Generate an API token (`Zone → Cache Purge`) — store as `CF_API_TOKEN`.
You'll need a sibling purge script (out of scope for this runbook;
the Bunny script is the only one wired into the workflow today).
5. Set `ASSET_CDN_URL=https://cdn.{domain}` in Coolify.
Cloudflare's default cache TTLs and "Smart Routing" can override the
`Cache-Control` headers the origin sends. Pin explicit *Cache Rules* on
the zone if behaviour diverges from the table above.
## Image optimisation (opt-in, Phase 2)
We deliberately do NOT enable image optimisation on day one — it adds cost
and operational surface area without a measured benefit on a small footprint.
**Enable Bunny Optimizer when any of these trigger:**
- Outbound image bandwidth from the CDN exceeds ~10 GB/month, OR
- User uploads (avatars, tenant logos, post images) cross ~1 000/month, OR
- Lighthouse's *Largest Contentful Paint* sits above 2.5 s and the audit
attributes the largest portion to images.
Steps to enable:
1. In the Bunny pull zone, *Optimizer → Enable* (~$9.50/month).
2. Tag image-heavy URLs with the size hint, e.g.
`https://cdn.{domain}/uploads/avatar.png?width=200&format=webp`.
3. Adjust the web app's image components to emit the `?width=&format=`
query string and a `<picture>` block with WebP/AVIF fallbacks.
4. Re-run Lighthouse against the staging URL and confirm LCP improvement.
## Rollback
| Failure mode | Action |
| ----------------------------------------------------- | ---------------------------------------------------------------------------- |
| CDN edge serves stale/corrupt assets | Run `pnpm exec tsx scripts/cdn/bunny-purge.ts` (whole-zone purge). |
| CDN provider outage / TLS failure | Unset `ASSET_CDN_URL` in Coolify and redeploy — Vite emits origin-relative paths next build. |
| Misconfigured `Cache-Control` shipped (e.g. `immutable` on `/fonts/`) | Push a fix, deploy, then whole-zone purge. |
| Need to fully cut over to the alternative provider | Provision the new pull zone, switch `ASSET_CDN_URL` + `BUNNY_*` secrets, redeploy. Old `cdn.{domain}` CNAME can stay until the new one stabilises. |
No dedicated feature flag is needed — `ASSET_CDN_URL` *is* the flag. Empty
or unset → no CDN detour, anywhere.
## Security notes
- **CDN-as-MITM:** Bunny / Cloudflare see every asset request including
Referrer headers. Static brand assets are fine; never route
`/uploads/<user-content>` through a public-cache zone — those paths
inherit the `no-store` default and stay on the origin.
- **CORS:** When fonts/images load via `fetch` (e.g. preload-on-demand),
the CDN must forward `Access-Control-Allow-Origin: https://app.{domain}`
from the origin. Bunny forwards CORS headers transparently; verify in
the smoke tests if you add cross-origin font loading.
- **CSP:** The Content-Security-Policy in `server/server.ts` keeps a
`base-uri 'self'; frame-ancestors 'self'` policy. When you switch on
the CDN, extend `script-src`, `style-src`, `font-src`, and `img-src`
to include the CDN hostname (separately tracked).
- **Origin shield + IP allowlist:** Bunny's Origin Shield gives a single
outgoing IP range. Optionally whitelist those IPs at the origin
firewall so attackers cannot bypass the CDN by hitting the origin
directly.
- **Cache poisoning:** Only assets that emit `public, max-age=…` go to the
edge. Loader-rendered HTML defaults to `no-store`; loaders that need
caching must opt in explicitly and set the right `Vary` headers.
+173
View File
@@ -0,0 +1,173 @@
# Backup CLI Runbook
Operator guide for `@toolctrl/cli backup:*`. The CLI ships in `packages/cli` and
is callable as `toolctrl-cli backup <subcommand>` once the workspace is
installed.
## Quick reference
| Command | Purpose |
| ------------------ | ------------------------------------------------------------- |
| `backup:create` | Dump Postgres (+ Storage, + ClickHouse) and upload to S3. |
| `backup:list` | List backups in the bucket with classification + encryption. |
| `backup:rotate` | Apply daily/weekly/monthly retention. |
| `backup:restore` | Download, decrypt, extract; pg_restore against a target DSN. |
| `backup:verify` | Restore into a `verify_<uuid>` schema and run sanity checks. |
## 1. Bucket + IAM setup
The backup bucket is intentionally separate from the application storage
bucket so a compromised app credential cannot pivot to the backups.
```bash
# MinIO example (staging)
mc mb staging-minio/toolctrl-backups
mc anonymous set none staging-minio/toolctrl-backups
# AWS S3 example
aws s3api create-bucket --bucket toolctrl-backups \
--create-bucket-configuration LocationConstraint=us-east-1
aws s3api put-bucket-encryption --bucket toolctrl-backups \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket toolctrl-backups \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
```
Create a dedicated IAM user / access key pair with `PutObject`,
`GetObject`, `ListBucket`, `DeleteObject` on the backup bucket only. Use those
values for `BACKUP_S3_ACCESS_KEY_ID` + `BACKUP_S3_SECRET_ACCESS_KEY` (do **not**
re-use the app `STORAGE_*` credentials).
For long-term retention, enable Object Lock with COMPLIANCE mode and / or MFA
delete so a rotated IAM key cannot purge history.
## 2. Encryption keys
The default encryption method is [age](https://age-encryption.org).
```bash
# Generate a key pair on a trusted workstation
age-keygen -o ~/.age-toolctrl-backup.key
# Output:
# Public key: age1example...
# Identity (private key) is at ~/.age-toolctrl-backup.key
# Set the public key in the deployment env
echo 'BACKUP_AGE_PUBLIC_KEY=age1example...' >> /etc/toolctrl/backup.env
```
**Store the identity file (private key) somewhere the running app does not have
access to** — a password manager, a tamper-evident envelope, a hardware token.
The public key alone is what the create flow needs; the private key only
appears in restore / verify environments via `BACKUP_AGE_IDENTITY_FILE`.
If the operator already has a GPG fleet, set `BACKUP_ENCRYPTION=gpg` and
`BACKUP_GPG_RECIPIENT=<key id>` instead.
> **Warning:** Lose the identity file and the backups are unrecoverable. The
> CLI cannot bypass age encryption.
## 3. Environment template
```env
# Backup bucket
BACKUP_S3_ENDPOINT=https://s3.your-provider.com
BACKUP_S3_REGION=us-east-1
BACKUP_S3_BUCKET=toolctrl-backups
BACKUP_S3_ACCESS_KEY_ID=AKIA...
BACKUP_S3_SECRET_ACCESS_KEY=...
BACKUP_S3_FORCE_PATH_STYLE=true # MinIO/R2; AWS uses host-style
# Encryption (age default)
BACKUP_ENCRYPTION=age
BACKUP_AGE_PUBLIC_KEY=age1example...
# Restore-side only:
# BACKUP_AGE_IDENTITY_FILE=/secrets/age-backup.key
# Retention
BACKUP_RETENTION_DAILY=7
BACKUP_RETENTION_WEEKLY=4
BACKUP_RETENTION_MONTHLY=12
# Optional
BACKUP_INCLUDE_CLICKHOUSE=false
```
Postgres source is picked up from `DIRECT_URL` (preferred — bypasses the
PgBouncer pool so prepared statements work) or `DATABASE_URL` as a fallback.
## 4. Daily cron
```cron
# /etc/cron.d/toolctrl-backup
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com
0 3 * * * deploy /opt/toolctrl/scripts/cron/backup-daily.sh >> /var/log/toolctrl-backup.log 2>&1
```
Example wrapper:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd /opt/toolctrl
node ./packages/cli/bin/toolctrl-cli.mjs backup create
node ./packages/cli/bin/toolctrl-cli.mjs backup rotate
```
For docker-compose deployments:
```yaml
backup-cron:
image: alpine:latest
volumes:
- ./scripts/cron/backup-daily.sh:/script.sh:ro
command: ["sh", "-c", "echo '0 3 * * * /script.sh' | crontab - && crond -f"]
depends_on: [postgres]
env_file: .env
```
Wire failures into your alerting channel (Sentry, Slack, PagerDuty) — an
untested backup is no backup, but an unalerted failed backup is the same
risk.
## 5. Disaster recovery drill
Run **at least monthly**:
```bash
# 1. Pick yesterday's backup
toolctrl-cli backup list
# 2. Verify in-place (creates verify_<uuid>, drops it after)
toolctrl-cli backup verify backup-2026-05-17T030000Z-aabbccdd.tar.gz.age
# 3. End-to-end DR drill (restore into a scratch host)
DATABASE_URL=postgres://dr-test/restored \
BACKUP_AGE_IDENTITY_FILE=/secrets/age-backup.key \
toolctrl-cli backup restore backup-2026-05-17T030000Z-aabbccdd.tar.gz.age --yes
```
Document the drill outcome in the incident channel or your internal ops ledger.
If the drill becomes a repository-maintained runbook, add the Markdown file
first and link it from this section.
## 6. Troubleshooting
| Symptom | Likely cause | Fix |
| ------------------------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `pg_dump exited with code 1` | `DIRECT_URL` missing or pgbouncer in the connection. | Set `DIRECT_URL` to the port-5432 DSN, not the pooler. |
| `BACKUP_AGE_PUBLIC_KEY is required` | `BACKUP_ENCRYPTION=age` but no public key in env. | Generate via `age-keygen` and set the value. |
| `age: error: failed to read header: parsing recipient` | Identity file does not match the public key. | Decrypt with the matching identity; never delete the key store. |
| `clickhouse-client: command not found` | `BACKUP_INCLUDE_CLICKHOUSE=true` but no client on PATH. | Either install the client or set the flag to `false`. |
| `backup:verify` reports "no tables present" | Restore ran against an empty DSN. | Confirm `DATABASE_URL` points to a non-empty DB and that pg_restore succeeded above. |
## 7. Sizing
Backup size grows ≈ `DB dump size` + `Σ(storage object sizes)` (no
compression on objects, only on the dump). Track via Prometheus
`s3_object_size_bytes` and alert when daily delta is below historic minimum
(a silent-fail symptom).
+186
View File
@@ -0,0 +1,186 @@
# PgBouncer Operations Runbook
PgBouncer multiplexes Prisma's per-process connection pools onto a small set
of real Postgres backends. The dev compose stack and the staging Coolify
stack both terminate application traffic at `pgbouncer:6432`; Prisma Migrate
keeps a separate path through `postgres:5432` (`DIRECT_URL`).
Related task: [`docs/improvement-plan/03-scaling-infrastructure/pgbouncer.md`](../improvement-plan/03-scaling-infrastructure/pgbouncer.md).
## Topology
```
+--------------+ +-------------+ +----------+
| apps (web, | 6432 | pgbouncer | 5432 | postgres |
| api, worker) +--------->+ transaction +-------->+ 16-alp. |
| Prisma client| | pool, 25/db | | |
+------+-------+ +-------------+ +-----^----+
| |
| DIRECT_URL (5432) — only for prisma migrate ---+
v
prisma migrate / db push / db pull
```
- `DATABASE_URL = postgres://...@pgbouncer:6432/<db>?pgbouncer=true&connection_limit=10`
- `pgbouncer=true` tells Prisma to skip prepared-statement caching.
- `connection_limit=10` caps Prisma's internal pool — PgBouncer pools again
on top, doubling the budget without this would defeat the purpose.
- `DIRECT_URL = postgres://...@postgres:5432/<db>` — read by
`prisma.config.ts` for `migrate`, `db push`, `db pull`, `db seed`.
## Local development
```bash
# Boot Postgres + PgBouncer side by side. The compose service is intentionally
# Docker-network-only — no host port mapping for 6432.
docker compose up postgres pgbouncer
# In another shell:
DIRECT_URL="postgres://user:pass@localhost:5432/boilerplate" \
DATABASE_URL="postgres://user:pass@localhost:6432/boilerplate?pgbouncer=true&connection_limit=10" \
pnpm dev
```
If you do not have a Postgres service in `docker-compose.yml` yet, point both
URLs at your existing local Postgres on port 5432 (skip PgBouncer entirely).
`prisma.config.ts` falls back to `DATABASE_URL` when `DIRECT_URL` is unset.
## Coolify (staging) deploy procedure
The pooler must run on the same Docker network as Postgres. The current
operator-owned deployment source is the Coolify service/network config; the
archived [`STAGING-ACCESS.md`](../improvement-plan/archive/STAGING-ACCESS.md)
is historical inventory only and must not override live Coolify state.
1. **Generate `userlist.txt`.** Postgres on staging is initialised with
`password_encryption=scram-sha-256`, so the SCRAM hash already lives in
`pg_authid.rolpassword`. From the Postgres container:
```bash
psql -U postgres -tAc \
"SELECT '\"'||rolname||'\" \"'||rolpassword||'\"'
FROM pg_authid
WHERE rolname IN ('boilerplate','pgb_admin');" > userlist.txt
chmod 600 userlist.txt
```
Drop the file into a Coolify volume mount that the pgbouncer container
will see at `/etc/pgbouncer/userlist.txt`. **Never commit a real hash.**
2. **Create the Coolify service.**
- Image: `edoburu/pgbouncer:latest`
- Network: `coolify` (must match Postgres)
- Mounts:
- `infra/pgbouncer/pgbouncer.ini` → `/etc/pgbouncer/pgbouncer.ini:ro`
- `userlist.txt` (from step 1) → `/etc/pgbouncer/userlist.txt:ro`
- No public port mapping — Traefik should not see 6432.
- Healthcheck:
`pg_isready -h 127.0.0.1 -p 6432 -U pgb_admin`
3. **Wait for the pooler to be `healthy`.** Verify from inside the network:
```bash
docker exec <pgbouncer-id> psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncer -c "SHOW STATS;"
```
4. **Switch app env vars.** In Coolify, on `web`, `api`, `worker`:
- `DATABASE_URL=postgres://<database-user>:${PG_PASS}@<pgbouncer-service>:6432/<database>?pgbouncer=true&connection_limit=10`
- `DIRECT_URL=postgres://<database-user>:${PG_PASS}@<postgres-service>:5432/<database>`
Use service and database names from the current Coolify project state; do
not copy archived staging inventory into a live cutover.
5. **Restart apps last.** Apps must boot only _after_ the pooler reports
healthy; otherwise the first Prisma query fails and the container loops.
## Migration runbook (production cutover)
1. Provision PgBouncer in Coolify but leave app env vars unchanged.
2. Verify `SHOW STATS` and `SHOW POOLS` respond.
3. Smoke-test from a one-off container:
```bash
docker run --rm --network coolify \
postgres:16-alpine psql \
"postgres://<database-user>:${PG_PASS}@<pgbouncer-service>:6432/<database>" \
-c "SELECT 1;"
```
4. Roll out `DIRECT_URL` to all apps first (no behaviour change because
`prisma.config.ts` falls back to `DATABASE_URL`).
5. Roll out the new `DATABASE_URL` (with `pgbouncer=true&connection_limit=10`).
Coolify will recreate containers — they must reconnect to the pooler.
6. Watch `pgbouncer` logs for `WARNING|ERROR|FATAL`.
7. Confirm `prisma migrate status` from a CI container still passes (uses
`DIRECT_URL`).
## Tuning knobs
| Knob | Default | When to change |
| --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pool_mode` | `transaction` | Stay on `transaction` for web traffic. Long-lived advisory locks or `LISTEN/NOTIFY` need `session` mode on a separate logical database. |
| `default_pool_size` | `25` | Rule of thumb: `postgres.max_connections / number_of_app_pools / 1.5`. Hetzner Postgres is provisioned with `max_connections=200`, so 25 per logical DB × app stack still leaves headroom. |
| `max_client_conn` | `1000` | Raises the upper bound of _client_-side connections PgBouncer will accept. Bumping past 1000 has memory cost (~12 KB/conn). |
| `reserve_pool_size` | `5` | Burst buffer when the main pool is exhausted. Increase only if `SHOW POOLS` shows recurring `cl_waiting`. |
| `server_idle_timeout` | `600` | Lower if Postgres is rapidly recycled (cloud-managed). Higher = fewer reconnects. |
| `query_timeout` | `0` | Leave at `0`. Per-query timeouts live in Prisma / API layer; PgBouncer killing a long report query is hard to debug. |
| `server_reset_query` | `DISCARD ALL` | Required for transaction pooling — prevents tenant state from leaking between transactions on the same backend. Do not weaken. |
Grafana dashboard lives under
`infra/grafana/dashboards/` (provisioned by Phase 02 `#35`).
Panels of interest: pool wait time p95, server connection count vs.
`default_pool_size`, total clients vs. `max_client_conn`.
## Troubleshooting
### `FATAL: prepared statement "s0" already exists`
PgBouncer in transaction pool mode reused a server-side connection that
still has the prepared statement cached. Confirm `DATABASE_URL` has
`?pgbouncer=true` and Prisma 5+ is in use. Without the flag, Prisma keeps
local prepared statements and the next transaction lands on a different
backend.
### `error: too many clients already`
Postgres ran out of connections. Either:
- `default_pool_size` × number-of-app-pools > `max_connections` — lower
pool size or raise `max_connections` on Postgres.
- Prisma's `connection_limit` is unset, so each app process opens its own
default pool (CPU + 1) against PgBouncer. Set `connection_limit=10` (or
lower) in the query string of `DATABASE_URL`.
### `cl_waiting > 0` persistently in `SHOW POOLS`
Application sustained pool exhaustion. Options:
- raise `default_pool_size` (and double-check `max_connections` headroom),
- introduce a read replica (see `03-scaling-infrastructure/read-write-split.md`),
- inspect slow queries — long-running transactions hold backends.
### Migrations fail with "prepared statement" or DDL errors
`DIRECT_URL` is wrong or missing. `prisma migrate` must hit Postgres
directly. Verify `prisma.config.ts` reads `DIRECT_URL`:
```bash
DATABASE_URL=postgres://...@pgbouncer:6432/db?pgbouncer=true \
DIRECT_URL=postgres://...@postgres:5432/db \
pnpm exec prisma migrate status
```
### Healthcheck flapping
`pg_isready` returns non-zero whenever PgBouncer cannot reach Postgres.
Check Postgres health first; PgBouncer surfaces an empty server pool as
unhealthy.
## Rollback
To bypass PgBouncer without code changes:
1. In Coolify, change `DATABASE_URL` to point at Postgres directly
(port 5432, drop `?pgbouncer=true&connection_limit=10`).
2. Restart `web`, `api`, `worker`.
3. Stop the `pgbouncer` Coolify service.
`DIRECT_URL` stays valid — it never depended on the pooler.
@@ -0,0 +1,196 @@
# Prod-Rollout HiveGrid — Runbook (dedizierter Hetzner-Account)
> Umsetzung von **AD-24** (Prod unter eigenem Hetzner-Account, self-contained). Stand 2026-06-19.
> **Go-Gates:** Jeder Schritt mit 🟥 ist kostenwirksam / schwer reversibel und wird **vor** dem `create`
> einzeln mit dem Owner (Raffael) bestätigt. 🟩 = read-only / vorbereitend.
## 0. Voraussetzungen (🟩 vorhanden)
- Hetzner-API-Token (Prod): `~/.cli-tokens/hcloud-hivegrid-prod` — verifiziert (Account leer).
- S3 (Prod): `~/.cli-tokens/prod-s3-bucket-hetzner-credentials` — Auth verifiziert (Projekt p15040939, fsn1), Bucket noch nicht angelegt.
- Cloudflare (Zone `hivegrid.de`): `~/.cli-tokens/cloudflare-token` (Global Key + Email → `X-Auth-Email`/`X-Auth-Key`).
- Gitea/CI: Deploy-Pipeline `.gitea/workflows/deploy.yml` (Promote-Pfad → Prod).
- Entscheidungen: Ingress = **Cloudflare-Proxy + Coolify/Traefik** (kein Hetzner-LB). Region **fsn1**, x86 **cx**-Linie.
## 1. Netzwerk-Fundament (🟥 Provisionierung, aber günstig/reversibel)
1. **SSH-Key** im Prod-Account hinterlegen (aktuell 0) — Public-Key des Deploy-/Admin-Keys.
2. **Private Network** (Hetzner Cloud Network), z.B. `10.0.0.0/16`, Subnetz fsn1.
3. **Firewall**:
- eingehend `80,443/tcp` → control/web/api (bzw. nur über Cloudflare-IP-Ranges, wenn proxied).
- `22/tcp` SSH nur von Admin-/Bastion-IP.
- `db` + `services`: **kein** eingehender Public-Traffic, nur über das Private Network.
## 2. Server provisionieren (🟥 kostenwirksam — Go pro Batch)
Alle in **fsn1**, im Private Network, mit Firewall + SSH-Key. cx-Linie (x86):
| VM | Typ | Specs | Zweck |
|---|---|---|---|
| `hivegrid-control` | cx33 | 4/8/80 | Coolify + Orchestrierung/Bastion |
| `hivegrid-web` | cx33 | 4/8/80 | Web (SSR) |
| `hivegrid-api` | cx33 | 4/8/80 | API |
| `hivegrid-worker` | cx43 | 8/16/160 | 5 Worker-Queue-Gruppen + Meili + ClamAV + mCaptcha |
| `hivegrid-db` | cx33 | 4/8/80 | Postgres + PgBouncer + Redis |
4. **Volume** (~50 GB) an `hivegrid-db` für Postgres-Daten (nicht auf Boot-Disk).
5. (optional) Server-Backups für `hivegrid-db` aktivieren.
## 3. Coolify auf control (🟥)
6. Coolify auf `hivegrid-control` installieren (eigenständige Prod-Instanz).
7. `web/api/worker/db` als **remote Server** in Coolify einhängen (über Private Network).
8. Container-Registry-Zugang (Gitea-Registry) + Cosign-Keys in Coolify hinterlegen.
## 4. Daten- + Zentral-Dienste (🟥)
9. **db-VM:** Postgres (+ DB `hivegrid`, DB `mcaptcha`), **PgBouncer** (Transaction-Pooling, Port 6432), Redis. `DATABASE_URL` → PgBouncer, `DIRECT_URL` → 5432.
10. **worker+services-VM:** Meilisearch (Master-Key), ClamAV (clamd), mCaptcha (zeigt auf db-Postgres `mcaptcha` + Redis).
11. **DEK-Master-Key** (AD-8) generieren + sicher hinterlegen (nicht ins Repo).
12. **S3-Bucket** `hivegrid-prod` (fsn1) anlegen + Versioning; `prod-s3-bucket-hetzner-credentials` um `bucket=hivegrid-prod` ergänzen.
## 5. App-Env + Deploy (🟥)
13. Prod-Env je App setzen (analog Staging-Keys, aber **Prod-Werte**): `DATABASE_URL/DIRECT_URL`, `REDIS/BULLMQ_REDIS_URL`, `MEILI_URL/MEILI_API_KEY`, `MCAPTCHA_*`, `CLAMAV_HOST/PORT`, `SESSION/CRYPTO/JWT`-Secrets, `APP_BASE_DOMAIN`/Host-Vars (Prod-Domains), Sentry `SENTRY_ENVIRONMENT=production`. **`WEB_NOINDEX` NICHT setzen** (Prod soll indexierbar sein).
14. Migrationen: `prisma migrate deploy` als Pre-Deployment-Command (wie Staging).
15. Promote-Deploy über die Pipeline (oder Coolify) → Images ausrollen.
## 6. Ingress / DNS (🟥 outward-facing)
16. **Cloudflare-Records** (Zone `hivegrid.de`):
- `app.` , `api.` , `captcha.` → Prod-IPs, **proxied** (orange).
- `admin.` → Prod-IP, **dns-only** + Firewall/Cloudflare-Access-Restriktion.
- **Kein** Wildcard.
17. Coolify/Traefik: Domains je App + Let's-Encrypt am Origin; Cloudflare SSL-Modus **Full (strict)**.
## 7. Endabnahme (🟩 verifizieren)
- `app./api.` `/healthz` + `/readyz` = 200 (DB erreichbar, Migration angewandt).
- Login inkl. 2FA-Gate; mCaptcha greift; Suche (Meili) antwortet; File-Upload-Scan (ClamAV) ok.
- **Prod ist indexierbar:** `/login` trägt **kein** `X-Robots-Tag: noindex`; `robots.txt` allow-all.
- Sentry-Events landen unter `environment=production`.
## 8. Rollback / Notfall
- Deploy-Rollback: vorheriges Image-Tag in Coolify re-deployen.
- DB: Volume-Snapshot / Backup einspielen (forward-only Migrationen → Inverse-SQL als Kommentar je Migration).
- DNS: TTL niedrig halten während des Cutovers.
## Inventar (provisioniert 2026-06-19, fsn1)
| Ressource | Name | ID | Public IP | Private IP |
|---|---|---|---|---|
| Server control | `hivegrid-prod-control` (cx33) | 143044798 | 157.90.243.228 | 10.0.0.2 |
| Server web | `hivegrid-prod-web` (cx33) | 143044800 | 167.233.109.142 | 10.0.0.3 |
| Server api | `hivegrid-prod-api` (cx33) | 143044801 | 167.233.110.100 | 10.0.0.4 |
| Server worker+services | `hivegrid-prod-worker` (cx43) | 143044803 | 49.13.24.190 | 10.0.0.5 |
| Server db | `hivegrid-prod-db` (cx33) | 143044805 | 178.105.208.72 | 10.0.0.6 |
| Volume (db-Daten) | `hivegrid-prod-db-data` 50 GB | 106081942 | — | `/dev/disk/by-id/scsi-0HC_Volume_106081942` |
| Network | `hivegrid-prod-net` 10.0.0.0/16 | 12347309 | — | — |
| Firewall app (22/80/443) | `hivegrid-prod-fw-app` | 11162316 | — | — |
| Firewall db (22) | `hivegrid-prod-fw-db` | 11162317 | — | — |
| SSH-Key | `hivegrid-prod-node1` (`~/.ssh/node1`) | 113985925 | — | — |
**Offen / Härtung:** SSH derzeit von `0.0.0.0/0` (key-only) erreichbar → später auf Admin-/Bastion-IP bzw. nur über `control` einschränken. Öffentliche 80/443 später auf Cloudflare-IP-Ranges begrenzen.
## Coolify (Schritt 3) — erledigt 2026-06-19
- Coolify **v4.1.2** auf `control` installiert (alle Container healthy).
- **Root-Admin headless** angelegt (`raffael@tool-ctrl.de`) via RootUserSeeder (`docker exec -e ROOT_USER_*` — Passwort-Policy: mixedCase+Ziffer+Symbol+uncompromised).
- **API aktiviert** + **API-Token** erzeugt (Sanctum, mit `team_id`; Coolifys `createToken`-Override braucht Team-Kontext, daher direkt über `tokens()->create`). `GET /api/v1/servers` → 200.
- Zugang (Secrets in `~/.cli-tokens/coolify-prod`, **nicht** im Repo): UI `http://coolify.hivegrid.de:8000` bzw. `http://157.90.243.228:8000`.
- ⚠ Härtung offen: Coolify-FQDN auf `https://coolify.hivegrid.de` (443) umstellen + Port 8000 wieder schließen.
## Coolify-Remote-Server + DB (Schritt 3–4) — erledigt 2026-06-19
- **Docker 29.6.0** auf web/api/worker/db installiert (get.docker.com), in Coolify als Remote-Server eingehängt → alle **`usable=true`**. node1-Key in Coolify (`privkey_uuid`). UUIDs in `~/.cli-tokens/coolify-prod`.
- Coolify-Projekt **HiveGrid** (`m74l9z6an57cw0316pir9rrb`) / Environment **production** (`h3woz1vm70yf6ajpzj0ch2fz`).
- **Postgres 16** (managed) + **Redis 7** (managed) auf db-VM. Creds + URLs in `~/.cli-tokens/hivegrid-prod-services`.
- **Cross-Server-Konnektivität (Designentscheidung):** Coolify-managed DBs laufen im Docker-Netz der db-VM; die App-Server (web/api/worker) erreichen sie über das **private Hetzner-Netz** (`10.0.0.6:5432/6379`), nicht über Container-DNS. DBs sind via `public_port` auf dem Host gebunden (`0.0.0.0`), aber **public durch fw-db (nur 22) dicht** — verifiziert: web→db privat OPEN, public DICHT.
**Offen (nächste Schritte):**
- Meilisearch + ClamAV + mCaptcha als Coolify-**Applications/Services** auf worker-VM (kein managed-DB-Typ), exposed aufs private Netz. mCaptcha nutzt Postgres-DB `mcaptcha` + Redis.
- PgBouncer vor Postgres (Compose auf db-VM) → `DATABASE_URL` über Pooler, `DIRECT_URL` direkt.
- DEK-Master-Key + S3-Bucket `hivegrid-prod`.
- App-Deploy (web/api/worker) nach **mitapp-Muster: image-retag / build-once-deploy-via-pull** (nicht rebuild) + `migrate deploy`.
- DNS `app./api./admin./captcha. → Prod-IPs` (Cloudflare) + Sentry (ToolCTRL-Instanz, prod-DSNs).
## Zentral-Dienste + Storage (Schritt 4) — erledigt 2026-06-19
- **Meilisearch v1.10 + ClamAV 1.4** als Coolify-Compose-Service `hivegrid-prod-search-av` auf worker-VM, privat exposed (`10.0.0.5:7700` / `:3310`). Verifiziert: Meili von api-VM cross-server `{"status":"available"}`, ClamAV healthy. Master-Key in `~/.cli-tokens/hivegrid-prod-services`.
- **mCaptcha-Stack** (mcaptcha + eigene Postgres + mcaptcha/cache) als Coolify-Service `hivegrid-prod-mcaptcha` auf worker-VM, FQDN `captcha.hivegrid.de` (DNS dns-only → worker, LE-Cert via Traefik im Hochlauf). Secrets gespeichert. **Folgeschritt:** Account+Sitekey in der mCaptcha-Instanz anlegen → neue `MCAPTCHA_SITEKEY_*`/`ACCOUNT_SECRET` in App-Env (Code-/Env-Anpassung).
- **S3-Bucket** `hivegrid-prod` (fsn1) angelegt + Versioning; `prod-s3-bucket-hetzner-credentials` ergänzt.
## App-Deploy — Muster verstanden (mitapp / build-once-retag)
- Image-Schema: `git.tool-ctrl.de/toolctrl-labs/hivegrid/{web,api,worker}:<commit-sha>`, build_pack `dockerimage`, Port **8080**.
- **Promote = retag, kein Rebuild:** Prod deployt denselben SHA-Tag, der getestet/gestaged ist (aktuell `9ae3963d…` = live auf Staging → Image existiert in der Gitea-Registry).
- **7 Coolify-Apps** (nicht 3!): `web`, `api`, `worker-general`, `worker-mail`, `worker-import`, `worker-webhook`, `worker-plugin` — web/api aus `/web`+`/api`-Image, die 5 Worker aus `/worker`-Image mit unterschiedlichem `WORKER_QUEUE_GROUP`.
- Deploy-Trigger: `scripts/coolify-deploy.mjs` (PATCH `docker_registry_image_tag` → deploy), `migrate deploy` als Pre-Deployment-Command.
- **Sentry (ToolCTRL-Instanz):** gleiche DSNs wie Staging, nur `SENTRY_ENVIRONMENT=production` (Estate-Regel: Staging↔Prod = Tag, kein eigenes Projekt).
**Offen (Go-Live-Phase):** Registry-Pull-Credential in Prod-Coolify · 7 Apps anlegen (Docker-Image) · App-Env Prod (aus Staging-Env transformiert: Prod-IPs/Domains, frische Secrets, prod-Sentry-Tag, **kein** WEB_NOINDEX) · erster Promote-Deploy + `migrate deploy` · DNS `app./api./admin.` · Endabnahme · mCaptcha-Sitekey + PgBouncer (Optimierung) als Folgeschritte.
## App-Deploy + Go-Live (Schritt 5–6) — LIVE 2026-06-19
- **Registry-Pull:** `docker login git.tool-ctrl.de` auf web/api/worker (User `gitea-admin`; Härtung: scoped Bot-PAT). Image `hivegrid/{web,api,worker}:9ae3963d…` (= Staging-Stand) gepullt.
- **7 Coolify-Apps** (Docker-Image): `hivegrid-web`, `-api`, `-worker-{general,mail,import,webhook,plugin}` (Worker je `WORKER_QUEUE_GROUP`). UUIDs in `~/.cli-tokens/coolify-prod`.
- **Prod-Env** aus Staging transformiert (Bug gefixt: bei doppelten build/runtime-Keys nicht-leeren Wert bevorzugen — sonst Mailjet u.a. leer → Worker-Crash). Frische, app-konsistente Secrets in `~/.cli-tokens/hivegrid-prod-app-secrets`. Demo-Seed AUS, `WEB_NOINDEX` raus (Prod indexierbar).
- **Postgres-Image korrigiert:** `postgres:16-alpine` → `pgvector/pgvector:pg16` (Migration `04_enable_pgvector` brauchte die Extension). Migration im laufenden web-Container ausgeführt → **alle Migrationen angewandt** (inkl. P2 `UserSession`+`TrustedDevice`).
- **DNS** (Cloudflare): `app.`/`admin.` → web-VM, `api.` → api-VM. SSL-Mode strict. `app`/`api` **proxied** (Edge-TLS/WAF), `admin` dns-only (Härtung folgt), `captcha` dns-only bis Cert.
- **Endabnahme grün:** `app.hivegrid.de` + `api.hivegrid.de` `/healthz` + `/readyz` = 200 (DB-backed), `/login` rendert „Anmelden | HiveGrid", **kein** X-Robots-Tag (indexierbar). Alle 7 Container healthy.
## Fix: Admin-SSO-Login-Loop (Cookie-Domain bei Multi-Host) — 2026-06-19
**Symptom:** SSO-Login loopte (Audit zeigte wiederholt `admin.login.sso.success`, aber Folge-Request nicht eingeloggt). **Ursache:** `resolveCookieDomain(APP_BASE_DOMAIN)` band das Session-Cookie an `app.hivegrid.de` → galt **nicht** für `admin.hivegrid.de` (getrennte Hosts). Auf Staging unsichtbar, weil dort app+admin denselben Host teilen. **Fix:** `APP_BASE_DOMAIN=hivegrid.de` (Parent-Domain) auf allen Apps → Cookie-Domain `hivegrid.de` gilt für app./admin./api.; abgeleitete Hosts bleiben korrekt (`getDerivedHosts` → app./admin./api.hivegrid.de). Verifiziert: `Set-Cookie: Domain=hivegrid.de`. **Lesson:** Multi-Host-Prod braucht `APP_BASE_DOMAIN` = Registrable-Domain, nicht den app-Host.
## Härtung/Abschluss-Runde — 2026-06-19 (nach SSO-Bestätigung)
- ✅ **Admin-Fallback aus:** `ADMIN_FALLBACK_LOGIN_ENABLED=false` → SSO (Authentik) ist einziger Admin-Pfad.
- ✅ **Sentry aktiviert:** `SENTRY_DSN_WEB/API/WORKER` aus zentraler Instanz gesetzt (`environment=production`), alle 7 Apps redeployed + healthy. (Waren vorher leer → Prod lief ohne Error-Tracking.)
- ✅ **mCaptcha komplett scharf:** Root-Cause des fehlenden LE-Certs war `service ... port is missing` (Traefik kannte Ziel-Port nicht — Compose-Service ohne Port-Deklaration). Fix: Compose mit korrekter Coolify-Magic `- SERVICE_FQDN_MCAPTCHA_7000=captcha.hivegrid.de` + `expose: 7000` → Host+Port-Label generiert, **LE-Cert geholt** (`captcha.hivegrid.de` HTTP 302, gültiges Cert). Account `hivegrid` + Captcha-Widget via mCaptcha-API angelegt → `MCAPTCHA_SITEKEY_LOGIN` + `MCAPTCHA_ACCOUNT_SECRET` + `MCAPTCHA_INSTANCE_URL` auf web gesetzt + redeployed. Creds in `~/.cli-tokens/hivegrid-prod-services`. `CAPTCHA_FAIL_OPEN=true` bewusst belassen (captcha aktiv, aber mCaptcha-Ausfall sperrt nicht den Login).
## CI-Promote verdrahtet + getestet — 2026-06-19
- **Gitea-Repo-Variablen (Production-Slot):** `COOLIFY_API_URL`, `COOLIFY_PROJECT_UUID`, `COOLIFY_ENVIRONMENT_NAME/UUID`, `COOLIFY_RESOURCE_UUIDS` (7 Apps), `COOLIFY_PROVISION_WORKER_GROUPS=false` + Secret `COOLIFY_TOKEN`. (Multi-Server ok: Resource-Auflösung ist server-agnostisch per Image-Name+Queue-Group; Auto-Provisioning aus, da Apps existieren.)
- **App-Tags auf `main` umgestellt** + `:main`-Alias in Registry erstellt (= aktueller SHA). Promote aliased Staging-Image → `:main` → deployt.
- **Worker-Apps korrekt konfiguriert** (vom CI-Drift-Check erzwungen, macht Prod robuster): Health-Ports 9101–9105 + `/readyz`-Health-Checks; Worker-ENV (WORKER_ENABLED/HEALTH_PORT/ADMIN_PORT/CONCURRENCY je Gruppe; general: OUTBOX_DISPATCHER_*/WORKFLOW_TRIGGER_OUTBOX_*; mail: TRANSACTIONAL_MAIL_OUTBOX_REQUIRED/MARKETING_SEQUENCES_ENABLED).
- **Test-Promote (runs 8436–8438):** iterativ alle Drifts gefixt; **8438 deployt Prod erfolgreich** (production worker groups + web-health + dependent deploys grün; Prod app/api `/readyz` 200, alle 7 Container healthy, `migrate status` = up to date).
- ⚠ **Ein Step bleibt rot:** „Verify production migration status after web deploy" läuft auf dem **CI-Runner** und braucht DB-Zugang — die Prod-DB liegt aber im **privaten Netz des separaten Prod-Accounts** (`10.0.0.6`), für den Runner strukturell unerreichbar. **Kein Prod-Problem** (Migration lief als Pre-Deploy + `migrate status` im Container = up to date). **Kein Prod-Problem** (Migration lief als Pre-Deploy + `migrate status` im Container = up to date).
- **Container-Verify-Versuch (revertiert `b833c82e`):** Step auf SSH-Jump (Runner→control→web-Container) umgeschrieben → scheiterte am **Preflight-Wiring-Guard** `scripts/verify-gitea-deploy-workflow.mjs` (erzwingt exakte CI-Runner-Step-Struktur + verbietet secret-backed env in `echo`/SSH). Der Guard läuft im **Preflight bei jedem Push** → die Änderung machte **die geteilte Pipeline rot** (auch Staging). **Sofort revertiert**, Preflight wieder grün. **Konsequenz:** Jede Änderung am Step erfordert, **zwei bewusste CI-Guards (Struktur + Secret-Leak) + Tests** umzuschreiben → invasiv, **mit Owner abzustimmen**. Optionen: (A) Guard so anpassen, dass der Step bei privater DB nicht erzwungen wird; (B) Container-SSH-Verify voll umsetzen **mit Host-Key-Pinning** (Security-Review: kein `StrictHostKeyChecking=no` in committetem CI-Code) + Guards/Tests; (C) so lassen — Promote deployt erfolgreich, nur der redundante Schluss-Check ist bei Prod rot.
## CI-Promote KOMPLETT GRÜN — 2026-06-20 (Run 8453)
Der erste echte Prod-Promote deckte **drei latente Bugs im nie-gelaufenen Production-Pfad** der geteilten `deploy.yml` auf (Prod gab es vorher nicht). Alle gefixt, lokal/manuell verifiziert, mit committeten Zwischenständen:
1. **migration-status-verify (war rot):** CI-Runner erreicht die private Prod-DB nicht (separater Account, AD-24). Fix: `DB_MIGRATE_STATUS_ALLOW_UNREACHABLE=true` (eingebauter Script-Mechanismus, erkennt P1001 → skip) + `DATABASE_URL`-Secret. Struktur unverändert → Wiring-Guard + Tests grün.
2. **production-dependent-health worker-check (war rot):** Step nutzte `remote_ssh`, **definierte es aber nie** (Copy-Paste-Lücke ggü. Staging) → „worker container not found". Fix: SSH-Setup + `remote_ssh` mit **ProxyCommand** (nicht ProxyJump — Key-Passthrough) über control-Bastion zur worker-VM (Multi-Server), gepinnte Host-Keys, Secret via Zwischen-var (umgeht Secret-Guard). Lokal verifiziert: Container gefunden + health-probe `ready`.
3. **web-health-revision-mismatch (war rot):** `/healthz` über `app.hivegrid.de` lieferte eine **40 min alte Cloudflare-gecachte** revision (`cf-cache-status: HIT`) statt der frisch deployten. Fix: **CF-Cache-Rule** `/healthz`+`/readyz` → bypass (Health-Endpoints nie cachen) + Purge. Kein deploy.yml-Eingriff.
- **Gitea-Config:** Prod-Coolify-Variablen + Secrets (`COOLIFY_TOKEN`, `DATABASE_URL`, `COOLIFY_PROD_SSH_KEY/KNOWN_HOSTS`, Host/Worker-IP-vars). Apps auf Tag `main`, `:main`-Alias in Registry.
- **Ergebnis:** Promote 8453 **komplett grün** (web-health + migration + dependent-health alle success). Prod läuft auf `94959b31`, app/api `/readyz` = ready. **Künftige Prod-Deploys:** `deploy.yml` workflow_dispatch `action=promote, staged_sha=<:staging-SHA>, backup_confirmed=true`.
## Härtungs-/Service-Runde 2 — 2026-06-20
- ✅ **mCaptcha scharf + proxied:** `captcha.hivegrid.de` jetzt Cloudflare-proxied; Sitekey + Account-Secret bereits gesetzt (`CAPTCHA_FAIL_OPEN=true` als Safety belassen).
- ✅ **PgBouncer (alle 7 Apps):** Coolify-Compose-Service `hivegrid-prod-pgbouncer` auf db-VM (`edoburu/pgbouncer:v1.23.1-p3`, transaction-pool, scram, `6432→5432`). Apps: `DATABASE_URL`→`10.0.0.6:6432?pgbouncer=true&connection_limit=10`, `DIRECT_URL`→`10.0.0.6:5432`. Kanarienvogel web `/readyz`=ready verifiziert, dann api+5 Worker nachgezogen — alle healthy. Creds in `~/.cli-tokens/hivegrid-prod-services`.
- ✅ **Coolify-Port 8000 zu (ohne Tunnel):** Coolify hinter den **schon laufenden Traefik** (coolify-proxy) gehängt via **Dynamic-Config-Datei** `/data/coolify/proxy/dynamic/coolify-ui.yaml` (`Host(coolify.hivegrid.de)`→`http://coolify:8080`, certresolver letsencrypt) — file-watch, **kein coolify-Restart, kein Selbst-Aussperr-Risiko**. LE-Cert geholt, `https://coolify.hivegrid.de/api/v1`=200. Firewall fw-app: **8000 entfernt** (jetzt 22/80/443). CI `COOLIFY_API_URL`→`https://coolify.hivegrid.de/api/v1`. Verifiziert: :8000 dicht, https-API 200, SSH+app/api weiter ok. (CF-Tunnel wurde erwogen, vom Owner abgelehnt → Traefik-Weg.)
- ✅ **Sentry gelöst (DSN-Host war falsch):** Die alte DSN nutzte `sentry.boiler.saas-ctrl.com` (→Staging-VM, 503) — toter Host, **richtiger Key/Projekt**. Über node1-SSH zum Dedi (`176.9.111.179`, self-hosted Sentry) die Projekte/Keys aus der Sentry-Postgres geholt: org `sentry`, Projekte `web=4`/`api=2`/`worker=3`. Korrekte DSNs (`https://<key>@sentry.saas-ctrl.com/<id>`) gesetzt auf web/api/worker + `SENTRY_ENVIRONMENT=production`. **Verifiziert:** Test-Envelope von der web-VM → HTTP 200; Container hat `…@sentry.saas-ctrl.com/4`; alle 7 healthy. Creds in `~/.cli-tokens/hivegrid-prod-services`. **Hinweis:** nutzt die generischen boilerplate-Projekte (web/api/worker, getrennt per `environment`-Tag) — dedizierte `hivegrid-*`-Sentry-Projekte wären Estate-Standard-Verfeinerung (Folgeschritt).
**Bewusst NICHT blind ausgeführt (echte Aufgaben, kein Einzeiler — Risiko/Begründung):**
- **Registry-Pull-Bot:** scoped Bot-PAT statt `gitea-admin` (Least-Privilege; aktuell funktional).
- **CF-Tunnel-Rest:** der bei Coolify-Port erwogene CF-Tunnel wurde verworfen; ein leerer Tunnel-Eintrag `hivegrid-prod-coolify` ohne Connector kann in Cloudflare gelöscht werden (API-Delete gab 400, harmlos da ohne cloudflared inaktiv).
## Offene Folgeschritte (nicht-blockierend)
- **mCaptcha:** LE-Cert für `captcha.hivegrid.de` verifizieren, dann proxied; Account+Sitekey anlegen → `MCAPTCHA_*` ins App-Env (bis dahin `CAPTCHA_FAIL_OPEN=true`).
- **Sentry:** Events unter `environment=production` verifizieren (DSN aus Staging übernommen).
- **Härtung:** SSH auf Bastion/Admin-IP einschränken · `admin.hivegrid.de` per Cloudflare-Access/IP schützen · scoped Registry-Bot statt `gitea-admin` · Coolify-FQDN auf https/443 + Port 8000 schließen.
- **Optimierung:** PgBouncer vor Postgres · Coolify Pre-Deploy-Migrate-Mechanik fürs CI-Promote klären (lief manuell, Coolify-pre-deploy griff nicht).
- **CI-Promote:** `deploy.yml`/Gitea-Variablen auf das Prod-Coolify verdrahten (für künftige `promote`-Deploys ohne manuelle Schritte).
## Härtung (Schritt 7) — teilweise erledigt 2026-06-19
- ✅ **SSH-Angriffsfläche reduziert:** neue FW `hivegrid-prod-fw-web` (nur 80/443) auf web/api/worker; **public SSH dort dicht**, Zugang nur über `control` (Bastion) + privates Netz. `control` behält 22/80/443/8000. `db`: **kein** public Port (fw-db rules geleert), nur privat. Verifiziert: control-SSH ok, web-SSH public dicht, web via control ok, app/api öffentlich ok, Coolify-Deploys (privates Netz) laufen weiter.
- ✅ Demo-Seed AUS, `WEB_NOINDEX` raus (prod indexierbar).
**Offene Härtung/Folgeschritte (priorisiert):**
1. **mCaptcha public-FQDN:** Traefik liefert noch Default-Cert für `captcha.hivegrid.de` (Compose-Service-Domain-Labels nicht generiert; fqdn per tinker reichte nicht). Container laufen intern. Domain am besten über Coolify-**UI** am `mcaptcha`-Service setzen → LE-Cert → proxied. Dann **Account+Sitekey** in der mCaptcha-Admin-UI anlegen → `MCAPTCHA_SITEKEY_*`/`ACCOUNT_SECRET` ins App-Env, `CAPTCHA_FAIL_OPEN=false`. Bis dahin: fail-open (kein Captcha-Zwang).
2. **Admin-SSO (Authentik) — eingerichtet 2026-06-19:** OIDC-Provider + App `hivegrid-admin-prod` in `auth.tool-ctrl.de` (Provider pk 31, explicit-consent-Flow, PKCE, signing-key gesetzt). Scopes openid/email/profile + **eigenes `groups`-Mapping** (`HiveGrid groups`) → liefert den `groups`-Claim für die Admin-Autorisierung (`toolctrl-admin`). `AUTHENTIK_ADMIN_CLIENT_ID/SECRET`, `AUTHENTIK_ISSUER_URL`, `AUTHENTIK_ADMIN_REDIRECT_URI`, `AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI` auf web gesetzt + redeployed (Creds in `~/.cli-tokens/hivegrid-prod-authentik`). **Verifiziert:** `/auth/admin/login` → 302 zu Authentik mit korrekter redirect_uri/PKCE; SSO-Button sichtbar; OIDC-Discovery grün; raffael/akeshan/dejan/arthur in `toolctrl-admin`. **Noch zu tun (interaktiv/Browser):** einmal per SSO einloggen + prüfen, ob der erste Login einen AdminUser JIT-provisioniert (sonst AdminUser in Prod-DB seeden). **Erst nach erfolgreichem SSO-Login** `ADMIN_FALLBACK_LOGIN_ENABLED=false` setzen (aktuell `true` als Backup, allowlist raffael/akeshan). `admin.hivegrid.de` dns-only; optional Cloudflare-Access davor.
3. **Coolify-Port 8000** schließen, sobald Coolify hinter `https://coolify.hivegrid.de` (443) läuft (Port aktuell offen, key+token-geschützt; für laufende API-Schritte noch gebraucht).
4. **Registry:** scoped Bot-PAT statt `gitea-admin` für den Image-Pull-Login.
5. **CI-Promote:** `deploy.yml`/Gitea-Variablen aufs Prod-Coolify verdrahten (Deploys liefen manuell; Coolify-Pre-Deploy-Migrate griff nicht → Migration manuell im Container).
6. **Sentry** Events unter `environment=production` verifizieren · **PgBouncer** (Pooling) ergänzen.
---
## Gesamt-Status (Stand 2026-06-20) — ✅ VOLLSTÄNDIG LIVE + GEHÄRTET
`app.`/`api.`/`admin.hivegrid.de` öffentlich (HTTPS), DB-backed (Postgres 16/pgvector + **PgBouncer** + Redis), P2-Set + 5 Worker-Gruppen, Revision `94959b31`.
**Alles erledigt:**
- ✅ **CI-Promote komplett grün** (Run 8453) — `workflow_dispatch action=promote, staged_sha=<:staging-SHA>, backup_confirmed=true`. Drei latente Multi-Server-Promote-Bugs gefixt (migration-status via `DB_MIGRATE_STATUS_ALLOW_UNREACHABLE`, dependent-health via ProxyCommand-SSH, web-health via CF-Cache-Bypass).
- ✅ **SSH-gehärtet** — control = Bastion, web/api/worker/db public-SSH dicht.
- ✅ **Admin-SSO** über Authentik (`auth.tool-ctrl.de`, OIDC, groups-Claim → `toolctrl-admin`).
- ✅ **mCaptcha** scharf + proxied (`captcha.hivegrid.de`).
- ✅ **PgBouncer** vor Postgres, alle 7 Apps über `6432`.
- ✅ **Coolify-Port zu** — nur noch `https://coolify.hivegrid.de` (Traefik+LE), `:8000` firewalled.
- ✅ **Sentry funktional** — `sentry.saas-ctrl.com` (alte `boiler.`-DSN war tot/503).
**Estate-Doku nachgezogen:** `infra/` README (Host-Inventar + DNS) + **ADR-016** (dedizierter Account + eigenes Coolify) + **LESSONS #13–#17** + onboard-pro-saas-Runbook.
**Rest-Folgeschritte (Least-Privilege, kein Druck):** scoped Registry-Bot statt `gitea-admin` · dedizierte `hivegrid-*`-Sentry-Projekte (statt geteilter boilerplate-Projekte) · Sentry-DSN-Host-Fix auch auf **Staging** nachziehen (dort steckt dieselbe tote `boiler.`-DSN) · leeren CF-Tunnel `hivegrid-prod-coolify` löschen.
@@ -0,0 +1,42 @@
# Production Build Warnings
`pnpm run build` is allowed to finish with known Vite/Rollup warning classes.
Treat this file as the operator reference before opening a deploy blocker for
build output that still exits with status `0`.
Last observed: 2026-05-28 via `pnpm run build`.
## Known Warning Classes
| Warning | Current interpretation | Escalate when |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Source maps are enabled in production` | Expected only when `TOOLCTRL_BUILD_SOURCEMAPS=1` is set for the Sentry release lane. Default web and bundle-budget builds should not emit this warning. Do not put secrets in source code; environment values must stay in `@toolctrl/config` and server-only boundaries. | Source maps expose hard-coded credentials, a default build emits maps unexpectedly, or a deploy target starts publishing server-only artifacts outside the intended build output. |
| `Generated an empty chunk` | Expected for React Router resource/API routes that have no client runtime. Empty route chunks are checked by `pnpm run verify:bundle-size`; unexpected names should be added only with an explicit rationale. | A new empty chunk is not covered by the bundle-size gate or appears for a user-facing UI route that should ship client code. |
| AWS/Smithy circular chunk warnings | Mitigated in the web Vite build by grouping `@aws-sdk/*` and `@smithy/*` into `vendor-aws-sdk`. The 2026-05-28 build emits `build/server/assets/vendor-aws-sdk-*.js` and no longer prints the circular Smithy/AWS reexport warning. | The circular warning returns, runtime AWS/S3 paths fail after build, the warning points to first-party modules, or the SDK versions/change set introduce a new broken execution order. |
| `dynamically imported ... but also statically imported` | Known Vite server-build warning where a lazy import cannot move a shared server module into a separate chunk because another route imports it statically. Current examples include DB, config, admin SSO audit, subscription entitlements, admin fallback helpers, and password reset helpers. | The warning starts involving a large client-only dependency, moves code into the default client shell, or a new performance slice claims a lazy split that this warning proves ineffective. |
## Recheck Commands
```text
pnpm run build
pnpm run verify:bundle-size
pnpm run verify:docs-links
```
To inspect only warning lines from a local build log:
```text
pnpm run build > /tmp/toolctrl-build.log 2>&1
rg -n 'Source maps are enabled|Generated an empty chunk|@smithy|@aws-sdk|vendor-aws-sdk|dynamically imported|dynamic import will not move' /tmp/toolctrl-build.log
```
## Triage Notes
- Do not change runtime build configuration just to silence one of the known
warnings above.
- Prefer one warning class per follow-up PR.
- Keep unknown warning classes visible. If a warning is suppressed or accepted,
add or update a regression gate next to the owner.
- For bundle concerns, use `pnpm run verify:bundle-size` as the first hard
signal. The raw `pnpm run build` chunk list is diagnostic, not the budget
gate.
@@ -0,0 +1,97 @@
# Streaming Exports — Operator Runbook
This document describes the streaming export pipeline shipped in Phase 03
task #46. It is the operator-facing complement to the detail plan
(`docs/improvement-plan/03-scaling-infrastructure/streaming-exports.md`).
## Why streaming
The previous export pipeline buffered every row of the target dataset in
worker memory, serialised it into a single string or `Uint8Array`, wrote
the result to a temporary file on disk, and only then uploaded the file
to S3. For large exports (>50k rows) this caused three operational
problems:
- worker OOM crashes on 200k+ row XLSX exports;
- user-visible latency in the multi-minute range because nothing
reached S3 until the very end;
- doubled I/O on the worker host (local write → S3 PUT).
The streaming pipeline replaces both the buffering and the tempfile with
per-format streamers and an `@aws-sdk/lib-storage` multipart upload.
## Per-format strategy
| Format | Streaming strategy | Hard cap | Notes |
|---|---|---|---|
| CSV | Async iterator → Node writable | – | `serializeExportTableToCsvStream` |
| JSON (NDJSON) | Async iterator → one JSON object per line | – | Default JSON mode |
| JSON (Pretty) | In-memory buffer of normalised rows | 10_000 | `ExportPrettyJsonTooLargeError` above the cap |
| XLSX | ExcelJS `WorkbookWriter` when `estimatedRowCount > 50_000`, in-memory workbook otherwise | 500_000 | `serializeExportTableToXlsxStream` |
| PDF | `@react-pdf/renderer` → `renderToStream` | 5_000 | `serializeExportTableToPdfStream` |
The format hard caps protect against format-specific failure modes (XLSX
stream-writer is fragile above 500k rows; PDF cost grows super-linearly).
They apply even to Enterprise tenants.
## Plan-based row caps
`packages/exports/src/limits.ts` exposes `resolveExportRowCap(plan, format)`
and `assertExportRowCap(rowCount, plan, format)`. The plan caps are:
| Plan | Cap (CSV/JSON) | XLSX cap | PDF cap |
|---|---|---|---|
| free | 10_000 | 10_000 | 5_000 |
| pro | 100_000 | 100_000 | 5_000 |
| enterprise | unbounded | 500_000 | 5_000 |
Throws `ExportRowCapExceededError` (code `EXPORT_ROW_LIMIT_PLAN`) when
the requested row count exceeds the resolved cap. Both the up-front
estimate and the streaming mid-pass enforce the cap so callers cannot
overshoot by under-reporting the estimate.
## S3 multipart upload sink
`createS3MultipartUploadSink({ client, bucket, key, contentType })`
returns `{ writeStream, completionPromise }`. The factory:
- uses `@aws-sdk/lib-storage`'s `Upload` class with `partSize: 8 MB`
and `queueSize: 4` (overridable);
- destroys the write stream when the upload rejects so producers see
the failure via the normal stream `error` event;
- relies on the AWS SDK to call `AbortMultipartUpload` on failure —
there is no orphaned multipart cleanup task to schedule.
## Progress tracking
The worker job (`apps/worker/src/jobs/export.ts`) writes
`export:progress:<exportJobId>` into the shared store every
`progressFlushInterval` rows (default 5_000) and again on success. The
payload is `{ processedRows, totalEstimate, updatedAt }` and the TTL is
1 hour.
The API exposes `GET /v1/exports/:exportJobId/progress` which simply
proxies the snapshot back. Front-ends poll every 2-5 s until the
artefact download URL becomes available.
## Backward compatibility
The in-memory façades (`serializeExportTableToCsv`,
`serializeExportTableToJson`, `serializeExportTableToXlsx`,
`serializeExportTableToPdf`) are intentionally preserved and continue to
work without code changes. They are still suitable for small synchronous
exports (e.g. the existing `Rows_Export.server.ts` page-bounded CSV
download in the web app).
New code that touches the worker pipeline should prefer the streaming
variants and the row-cap helper.
## Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| Job stuck at 0 processed rows | Worker cannot reach the shared store | Check `REDIS_URL` and the shared-store warning emitted at boot; the worker continues but progress is invisible |
| `EXPORT_ROW_LIMIT_PLAN` returned | Tenant requested more rows than their plan allows | Upgrade plan or split the export |
| `EXPORT_ROW_LIMIT_EXCEEDED` returned | Tenant requested more than the format hard cap | Switch to CSV/NDJSON for the bulk export |
| Multipart upload visible in S3 console as `incomplete` | Job failed mid-stream; SDK abort raced with check | AWS abandons the upload after `LifecycleRule` for `AbortIncompleteMultipartUpload` (recommended: 7 days). Make sure the bucket has one configured |
| 200k-row CSV export takes minutes | Row source `iterateRows` is not actually cursored | Verify the row source uses `prisma.findMany({ cursor, take: 1000 })` rather than a single unbounded query |
@@ -0,0 +1,189 @@
# Stripe Meters API — Operator Runbook
**Audience:** operators wiring usage-based billing after the Stripe SDK
13 → 19 migration (B-001, Task #28).
**Why:** Stripe 19 removed the legacy Usage-Records API
(`subscriptionItems.createUsageRecord` /
`subscriptionItems.listUsageRecordSummaries`) and `Recurring.aggregate_usage`
in favor of the **Billing-Meters API**
(`billing.meterEvents.create` / `billing.meters.listEventSummaries`).
The boilerplate worker (`ai-cost-stripe-report`) still drives overage
reporting from `AICostEvent` rows; only the Stripe wire format changed.
Operators must configure a Billing-Meter once and migrate existing
subscriptions to the new meter-backed price.
---
## Step 1 — Create the Billing-Meter
In the Stripe Dashboard (or via the API):
1. **Billing → Meters → Create meter**.
2. Configure:
- **Display name:** `AI overage cents`
- **Event name:** `ai-overage` *(the boilerplate hardcodes this id;
change it only if you also override `aiOverageStripeMeterId` /
`aiOverageMeterEventName` per plan).*
- **Aggregation:** `Sum`
- **Customer mapping:** map the event payload key
`stripe_customer_id` to the Stripe customer id (default — leave
as-is).
- **Value settings:** event payload key `value`, type
`numeric`. The reporter submits the rounded overage block-count
in cents.
3. Copy the resulting **Meter id** (`mtr_…`). You will store this in
`Tenant.aiQuotaOverrideJson.aiOverageStripeMeterId` (per-tenant)
or in the plan default (per-plan, future admin UI).
CLI alternative:
```bash
stripe billing meters create \
--display-name="AI overage cents" \
--event-name="ai-overage" \
--default-aggregation[formula]="sum" \
--customer-mapping[event_payload_key]="stripe_customer_id" \
--customer-mapping[type]="by_id" \
--value-settings[event_payload_key]="value"
```
---
## Step 2 — Create the metered product + price
1. **Products → Create product** → "AI Overage".
2. **Add price**:
- **Pricing model:** `Usage-based`
- **Meter:** select the meter created in Step 1 (`mtr_…`).
- **Per-unit cost:** the per-cent charge (typically `0.01` USD
because the meter aggregates cents).
- **Billing period:** `Monthly` (match the tenant's plan).
3. Copy the resulting **Price id** (`price_…`).
---
## Step 3 — Migrate active subscriptions
For every active subscription that had a metered usage-record line in
SDK 13.x:
1. **Add a new subscription item** referencing the new price from
Step 2. Stripe API:
```bash
stripe subscription_items create \
--subscription="sub_..." \
--price="price_..." \
--quantity=0
```
2. **Remove the old subscription item** that referenced the
pre-migration metered price (or archive its price so no new
subscriptions can attach it).
3. Confirm: `stripe subscriptions retrieve sub_... --expand=items.data.price`
shows the new price and the meter id in `price.recurring.meter`.
The boilerplate's overage configuration uses `aiOverageStripeMeterId`
as the sole identifier (the legacy `aiOverageStripeSubscriptionItemId`
field was retired with the Stripe-19 cleanup). Existing tenant JSON
overrides that still carry the legacy key are silently ignored by the
parser.
---
## Step 4 — Wire the boilerplate
1. **Per-tenant override** (admin route
`/admin/settings/pricing/ai-overage`):
```json
{
"aiOverageStripeMeterId": "mtr_test_abc...",
"aiMarkupMultiplier": 2.0,
"aiOverageBlockCents": 10
}
```
2. **Per-plan default** — edit `packages/entitlements/src/ai-overage.ts`
`standardAIOverageDefaults` and bake the meter id into the plan
blueprint (operator-friendly admin UI is a future task).
3. **Worker bootstrap** — ensure the Stripe-19 client is constructed
with `apiVersion: "2025-10-29.clover"` and the worker injects a
`StripeMeterEventSubmitter` whose `submit()` calls
```ts
stripe.billing.meterEvents.create(
{
event_name: "ai-overage",
payload: {
stripe_customer_id: tenant.stripeCustomerId,
value: String(roundedBlocks),
},
identifier: idempotencyKey,
timestamp,
},
{ idempotencyKey },
);
```
See `apps/worker/src/jobs/ai-cost-stripe-report.ts` for the
pure-by-deps interface (`StripeMeterEventSubmitter`).
---
## Step 5 — Verify with a test event
1. Toggle `AI_OVERAGE_SUBMISSION_ENABLED=true`.
2. Manually emit a test event:
```bash
stripe billing meter_events create \
--event-name="ai-overage" \
--payload[stripe_customer_id]="cus_test123" \
--payload[value]="100" \
--identifier="manual-test-$(date +%s)"
```
3. Confirm aggregation:
```bash
stripe billing meters list_event_summaries mtr_... \
--customer="cus_test123" \
--start-time=$(date -v-1d +%s) \
--end-time=$(date +%s)
```
You should see a non-zero `aggregated_value` for the meter event
you just submitted.
4. End-to-end: run `pnpm --filter @toolctrl/worker test
ai-cost-stripe-report` to confirm the worker keeps passing its 13
unit tests against the new wire.
---
## Rollback
The Meters API is **not trivially reversible** — once events are
submitted, the legacy Usage-Records API cannot ingest them. The safe
rollback path is to leave already-submitted events in place and revert
the SDK to 13.x via Git revert (commit
`4b2350f0c5a098d3c2dd0778f087cac5910ec2f9` and its follow-ups). The
operator-side meter + price can stay in place — Stripe ignores them
when no subscription item references them.
---
## Open items
- Admin UI per-plan meter-id editor (currently only per-tenant via
`aiQuotaOverrideJson`).
- Auto-creation of the meter on first operator-bootstrap (currently
manual via Dashboard / CLI).
- CLI helper `toolctrl-cli ai-cost stripe-init` to scaffold the
meter + product + price idempotently — listed in
`docs/improvement-plan/05-business-features/ai-cost-phase-3.md`
follow-ups.
+253
View File
@@ -0,0 +1,253 @@
# Tenant Snapshot — Operations Runbook
`tenant:snapshot` and `tenant:restore` ship inside `packages/cli` and let
operators clone a single tenant's Postgres rows + S3 storage objects into a
PII-anonymized archive, then replay that archive into another environment.
This runbook is the operator-facing companion to the implementation spec at
`docs/improvement-plan/03-scaling-infrastructure/tenant-snapshot.md`.
---
## When to use it
| Use case | Profile | Encryption | Restore target |
| --- | --- | --- | --- |
| **Bug repro** ("Customer X hit a workflow bug") | `full-anonymized` | `age` | local dev box |
| **QA fixture** (representative Pro-tier tenant) | `full-anonymized` | `age` | CI seed |
| **Sales demo** (realistic but anonymized) | `minimal-anonymized` | `age` | staging |
| **Tenant migration** to a dedicated instance | `none` (super-admin only) | `age` (mandatory) | the destination environment |
The default profile is `full-anonymized`. Never use `none` for any path that
leaves your production network.
---
## Anonymization profiles
Three profiles ship today. Picking one is a tradeoff between fidelity and
privacy risk. The full mapping table lives in
`packages/cli/src/commands/tenant/anonymize.ts`; the highlights:
| Field type | `full-anonymized` | `minimal-anonymized` | `none` |
| --- | --- | --- | --- |
| Email | `user-<hash>@anonymized.test` | `user-<hash>@anonymized.test` | (untouched) |
| First / Last name | random from a curated pool | **kept** (display names usable in QA UI) | (untouched) |
| Phone number | `null` | `null` | (untouched) |
| IP address | `127.0.0.<seeded>` | `127.0.0.<seeded>` | (untouched) |
| ZIP code | first two digits kept, rest reseeded | first two digits kept, rest reseeded | (untouched) |
| Stripe customer / subscription / payment-method id | `cus_anon<8 hex>` / `sub_anon<8 hex>` / `pm_anon<8 hex>` | same as full | (untouched) |
| Webhook / SSO / domain URL | `https://anonymized.test/<8 hex>` | same as full | (untouched) |
| Secrets, API keys, refresh tokens, payloads | `[REDACTED]` | `[REDACTED]` | (untouched) |
| Avatar URL | `https://anonymized.test/avatar/<8 hex>.png` | same as full | (untouched) |
**Determinism.** All anonymizers are seeded with `sha256(salt + original)`
where `salt` is a fresh 32-byte random value generated per snapshot. Two rows
that referenced the same original email get the same anonymized email — FK
integrity inside a snapshot is preserved. The salt itself is **not** stored
anywhere; only its `sha256` lives in the manifest as a reproducibility marker.
---
## CLI usage
### Create a snapshot (default profile, age-encrypted, upload to S3)
```bash
toolctrl-cli tenant snapshot \
--tenant-id ten_abc123 \
--output s3://toolctrl-snapshots/ten_abc123/$(date -u +%Y%m%dT%H%M%SZ).tar.gz.age
```
The CLI requires `BACKUP_AGE_PUBLIC_KEY` (recipient public key) plus
`BACKUP_S3_*` credentials. See `docs/ops/backup-cli.md` for the env-var
catalog — the snapshot command reuses the same encryption layer.
### Restore into a dev box
```bash
NODE_ENV=development toolctrl-cli tenant restore \
--tenant-id ten_devclone \
--source s3://toolctrl-snapshots/ten_abc123/2026-05-17T03:00:00Z.tar.gz.age
```
The command refuses to run under `NODE_ENV=production` unless the operator
passes `--allow-production-restore`. The confirmation prompt can be silenced
with `--yes` for non-interactive pipelines.
### List existing snapshots
```bash
toolctrl-cli tenant list-snapshots --bucket toolctrl-snapshots
```
### Super-admin escape hatch (`--profile=none`)
Raw, un-anonymized exports require an explicit confirmation flag:
```bash
toolctrl-cli tenant snapshot \
--tenant-id ten_abc123 \
--profile none \
--i-am-the-super-admin \
--output s3://toolctrl-snapshots/migration/ten_abc123.tar.gz.age
```
Without `--i-am-the-super-admin`, the command exits non-zero with a clear
error. Every snapshot — including the `none` profile — emits a structured
`[audit] TENANT_SNAPSHOT_CREATED` line to stdout. Operators are expected to
redirect that into their central audit log; set `auditFilePath` when calling
`runTenantSnapshot()` programmatically.
---
## DSGVO / GDPR notes
- **`full-anonymized` is the only profile that may leave EU production
infrastructure.** The other profiles ship un-anonymized display names
(`minimal`) or full PII (`none`).
- Snapshots are themselves sensitive (business workflows, schema layout,
org structure). Always set `BACKUP_ENCRYPTION=age` so the artifact can
only be decrypted by the operator who holds the private identity file.
- The salt is discarded after snapshot creation; only the `sha256(salt)`
travels in the manifest. There is no way to reverse-engineer the
original PII out of an anonymized row given just the snapshot file.
- If a tenant invokes their right-to-be-forgotten on the **source**
environment after a snapshot exists, the snapshot still contains the
anonymized projection (which by design contains no resolvable PII). For
`none` snapshots, the operator must manually destroy the archive.
---
## Restore drill (recommended cadence: monthly)
1. Snapshot a representative tenant from staging:
```bash
toolctrl-cli tenant snapshot --tenant-id <staging-tenant> \
--profile full-anonymized \
--output /snapshots/drill-$(date -u +%Y%m%d).tar.gz.age
```
2. Copy to a dev box and restore into a fresh database:
```bash
NODE_ENV=development DATABASE_URL=postgres://... \
toolctrl-cli tenant restore \
--tenant-id ten_drill_$(date +%s) \
--source /snapshots/drill-$(date -u +%Y%m%d).tar.gz.age \
--yes
```
3. Smoke test in the dev UI: login as a restored user, view a workflow,
confirm subscription state shows as expected.
4. Record the drill in the change log.
---
## How globals are handled in restore
Several tenant-scoped models carry foreign keys into **global** tables
(today only `User`). Earlier snapshots only emitted tenant-scoped rows,
so restoring into a truly empty target database failed for those
models with `ForeignKeyConstraintViolation`. Closed by Task G — the
extractor now harvests every referenced global id from the tenant
slice, fetches the rows, anonymizes them with the same per-snapshot
salt, and writes them to `globals/<Model>.jsonl` inside the archive.
Models that previously broke (now restore clean against an empty
target via the globals replay):
| Model | Foreign key into |
| ----------------- | ---------------- |
| `TenantUser` | `User` |
| `TenantIpAddress` | `User` |
| `UserRole` | `User` |
| `Event` | `User` |
| `Log` | `User` |
| `SupportTicket` | `User` |
The full list of `User`-referencing tenant-scoped models is
machine-enumerated in
`packages/cli/src/commands/tenant/models.ts::GLOBAL_REFERENCED_MODELS`
and asserted complete by `models.test.ts` against every prisma
schema relation.
### Restore modes for globals (`--globals=<mode>`)
The restore command accepts one of three strategies (default
`upsert`):
| Mode | Behaviour | When to use |
| -------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `upsert` | For each id, `findUnique` → if present, leave in place; else `create`. Idempotent. | Default. Merging a snapshot into a non-empty dev/staging. |
| `skip` | `createMany({skipDuplicates:true})`. Faster but silent on non-id unique conflicts. | Bulk restores where pre-existing rows are expected. |
| `error` | Same as upsert but aborts if any id already exists on target. | Sensitive restores into a verifiably-empty DB. |
```bash
# Default — overlay snapshot rows onto whatever already exists.
toolctrl-cli tenant restore --tenant-id ten_new --source ./snap.tar.gz
# Sensitive restore: refuse to proceed if any global is already there.
toolctrl-cli tenant restore --tenant-id ten_new --source ./snap.tar.gz \
--globals=error
```
### Platform-catalog globals
A second category of globals — `Entity`, `EntityGroup`, `Formula`,
`Onboarding`, `PromptFlow`, `RowComment`, `WorkflowBlock` — is
referenced by tenant-scoped models but **intentionally NOT** in the
snapshot. These are seeded once per deployment via migrations/setup
scripts; copying them per-tenant would be wrong. If a tenant slice
references a platform-catalog row that does not exist on the target,
the restore fails for that specific row and you must align the target
deployment's seed first. The list is asserted by
`models.test.ts::PLATFORM_CATALOG_GLOBALS`.
### Backward compatibility
Snapshots taken before this extractor landed have no `globals` array
in their manifest. The restore detects that, logs a warning, and
falls back to the pre-Task-G behaviour where the operator must
pre-seed referenced rows manually. Use `--include-globals=false` on
`tenant:snapshot` to produce a legacy-shaped archive intentionally.
### Anonymization of global rows
`User` rows in the globals slice are scrubbed by the same anonymizer
that handles tenant rows. The `full-anonymized` profile rewrites
`email`, `firstName`, `lastName`, `phone` and `avatar`, and nulls
credentials (`passwordHash`, `verifyToken`, `githubId`, `googleId`,
`totpSecretEncrypted`, `totpPendingSecretEncrypted`,
`mfaRecoveryCodeHashes`, `deletionRequestId`) so the restored user
cannot be logged into via the original credentials. The
`minimal-anonymized` profile keeps display names but still nulls
credentials. See
`packages/cli/src/commands/tenant/anonymize.ts::FULL_ANONYMIZED_PROFILE`
for the source of truth.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `Refusing to run --profile=none without --i-am-the-super-admin` | Guardrail working as intended. | Re-run with the flag if you are a super-admin and have a documented reason. Otherwise pick a different profile. |
| `Invalid --output: expected s3://<bucket>/<key>` | Output target malformed. | Use either a local path or a fully-qualified `s3://bucket/key/...` URL. |
| `Refusing to run tenant:restore in NODE_ENV=production` | Production restore guardrail. | Set `NODE_ENV` to a non-production value, or pass `--allow-production-restore` if you genuinely want to overwrite production. |
| `Row-count mismatch in N models` warning during restore | Target DB rejected some rows (FK or unique index conflict with pre-existing data). | Pick a fresh tenant id, or pre-clean the target DB. |
| `manifest.json: missing or invalid` | Archive truncated / wrong encryption key. | Verify download size against the S3 ETag; rerun with the correct `BACKUP_AGE_IDENTITY_FILE`. |
| Storage bucket mismatch (objects landed under wrong prefix) | `--storage-bucket` or `STORAGE_BUCKET` env mismatched between source and target. | Set the target storage bucket explicitly before re-running restore. |
| `tenant:restore aborted (--globals=error): <Model> had N pre-existing row(s)` | `--globals=error` saw an id already on target. | Re-run with `--globals=upsert` (default) to overlay or `--globals=skip` to drop conflicts. |
| `Manifest carries no globals slice (legacy snapshot)` warning | Snapshot was taken before Task G landed (or with `--include-globals=false`). | Either take a fresh snapshot, or pre-seed referenced globals on target before restoring. |
---
## Internals
- Tenant-scoped models enumerated: `packages/cli/src/commands/tenant/models.ts`
(`TENANT_SCOPED_MODELS`).
- Global-snapshot models enumerated: same file
(`GLOBAL_REFERENCED_MODELS`); platform-catalog globals on
`PLATFORM_CATALOG_GLOBALS`.
- Globals extractor: `globals-runner.ts::collectAndWriteGlobals()`.
- Profiles: `packages/cli/src/commands/tenant/anonymize.ts`.
- Manifest schema: `packages/cli/src/commands/tenant/manifest.ts`.
- Snapshot entrypoint: `runTenantSnapshot()` in `snapshot.ts`.
- Restore entrypoint: `runTenantRestore()` in `restore.ts`.
- Audit entry: `writeSnapshotAudit()` in `audit.ts`.
+381
View File
@@ -0,0 +1,381 @@
# Worker queue groups — operations guide
The standalone `apps/worker` runtime is split into five queue groups. Each
group is the same image and the same code; the only difference is the
`WORKER_QUEUE_GROUP` env variable that decides which BullMQ queues the
process subscribes to.
This page is the canonical worker topology reference. Shorter worker tables
in deployment, environment, or feature docs are summaries only and should link
back here instead of redefining queue ownership.
## Why five groups
A single worker process processed every queue. That had three operational
problems:
1. **Head-of-line blocking.** A long-running import (minutes) stalled
transactional mail and notifications that ran on different queues but
the same event loop.
2. **Wrong concurrency for the workload.** Webhook delivery needs high
parallelism (I/O-bound HTTP calls); imports need low parallelism
(CPU/memory-bound). One `WORKER_CONCURRENCY` value cannot suit both.
3. **No per-workload scaling.** When mail queues backlog we couldn't scale
mail capacity without also duplicating the import worker (waste of RAM
and Redis connections).
The split fixes all three by giving each workload its own process and its
own concurrency. Add replicas to only the group that needs them.
## Production worker matrix
Production-like Coolify environments must run one persistent worker service per
queue group. These are long-lived internal services from the same worker image,
not manual emergency processes and not public web apps. `worker-mail` with
`WORKER_QUEUE_GROUP=mail` is part of the baseline topology because
transactional mail, mail outbox, form-submission webhook delivery, and
email-sequence worker contracts are intentionally isolated from general outbox
fanout.
| Coolify app | Queue group | Owns | Health port | Admin/metrics port | Required fail-loud settings |
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------ | -------------------------------------------------------------------------- |
| `worker-general` | `WORKER_QUEUE_GROUP=general` | General package queues plus outbox dispatcher, AI-cost persistence, plugin hook fanout, workflow trigger fanout, and form-submission routing | `9101` | `9201` | `OUTBOX_DISPATCHER_REQUIRED=true`, `WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true` |
| `worker-mail` | `WORKER_QUEUE_GROUP=mail` | Transactional mail, transactional mail outbox bridge, email-sequence subscriber contracts, and form-submission webhook bridge | `9102` | `9202` | `TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true` in staging/production |
| `worker-import` | `WORKER_QUEUE_GROUP=import` | Import and export queues after runtime gates are enabled | `9103` | `9203` | `IMPORT_QUEUE_ENABLED=true` and `IMPORT_QUEUE_RUNTIME_READY=true` only after services are wired |
| `worker-webhook` | `WORKER_QUEUE_GROUP=webhook` | AI chatbot and browser automation queues | `9104` | `9204` | none beyond shared worker readiness |
| `worker-plugin` | `WORKER_QUEUE_GROUP=plugin` | Isolated WASM plugin invocation queue; idle before production plugin signoff | `9105` | `9205` | Plugin runtime gates from `docs/operations/plugin-lifecycle.md` |
The detailed queue mapping below is the source of truth for package queues,
subscriber queues, concurrency, and workload profile. Update this page first
when queue ownership changes; deployment-specific docs should keep only the
short app/group/port summary and link back here.
`worker-plugin` is required as a persistent Coolify service so the deploy
topology is stable, but production plugin execution is still gated. With
`PLUGIN_RUNTIME_PRODUCTION_ENABLED` unset or `false`, the plugin worker starts
as an idle topology placeholder and must not consume `toolctrl.plugin-invoke`.
Only after the external WASM sandbox signoff in
`docs/security/plugin-runtime-signoff.md` may operators set
`PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`, provide the trusted public keys, and
accept plugin invoke consumption as production-ready. The final live proof is
operator-run, inside both `worker-general` and `worker-plugin`:
`pnpm run verify:plugin-runtime-live`.
`worker-import` follows the same placeholder-first principle. Provision the
separate Coolify app with `WORKER_QUEUE_GROUP=import`, but keep
`IMPORT_QUEUE_ENABLED` and `IMPORT_QUEUE_RUNTIME_READY` unset until import,
export, and scan services are wired into the worker bootstrap. In that idle
state `/readyz` may be healthy with an empty queue list, which proves topology
without consuming `toolctrl.imports` or `toolctrl.exports`. When both runtime
gates are set, startup fails closed unless the worker has real import, export,
and scan service dependencies.
## Group → queue mapping
| Group | Package queues | Bootstrapped subscriber queues | Default concurrency | Default replicas | Workload profile |
| --------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `general` | `toolctrl.notifications`, `toolctrl.maintenance`, `toolctrl.file-scan` | Outbox dispatcher, `outbox.ai-cost`, `outbox.search-index`, `outbox.rag-index`, `outbox.audit`, `outbox.notification`, `outbox.plugin-hook`, `outbox.plugin-capability-use`, `outbox.workflow`, `outbox.form-submission-route`, `plugin-audit-retention` | 10 | 1 | Mixed short I/O, fanout, AI-cost persistence, indexing, audit persistence, plugin hook fanout, plugin capability-use audit persistence, workflow trigger fanout, form-submission routing. The `outbox.plugin-capability-use` subscriber intentionally defaults to concurrency `1` to keep capability-use hash chains linear; do not raise `PLUGIN_CAPABILITY_USE_CONCURRENCY` without store-level serialization. |
| `mail` | `toolctrl.email` | `outbox.mail`, `outbox.marketing` (email-sequence trigger only), `outbox.webhook` (form-submission delivery bridge), `email-sequence.step` | 5 | 2 | SMTP/HTTP throughput-bound; owns direct transactional mail delivery, transactional mail outbox bridging, form-submission webhook bridging, and email-sequence trigger/step subscriber contracts. Email-sequence subscribers require `MARKETING_SEQUENCES_ENABLED=true`; keep it unset or false in production until the remaining approval follow-ups in `docs/features/email-sequences.md` land and the Mailjet env pair is configured. Stale-lead/trial-expiring cron runner functions exist but are not production scheduler-registered yet; trial-expiring still needs a canonical trial-end source before that scheduler can be wired safely. The generic marketing outbound adapter/handler foundation exists in `packages/marketing-outbound` and `apps/worker/src/jobs/marketing-outbound.ts`, but it is pure handler foundation and does not claim active ownership of `outbox.marketing`; general marketing integration delivery is not bootstrapped in `apps/worker/src/index.ts` yet. |
| `import` | `toolctrl.imports`, `toolctrl.exports` after `IMPORT_QUEUE_ENABLED=true` and `IMPORT_QUEUE_RUNTIME_READY=true` | none | 1 | 1 | CPU/memory-bound, long-running; idle topology placeholder until import/export/scan services are wired |
| `webhook` | `toolctrl.ai-chatbot`, `toolctrl.browser-automation` | none | 20 | 3 | I/O-bound calls to external services |
| `plugin` | `toolctrl.plugin-invoke` | none | 5 | 2 | Third-party WASM plugin invocations, isolated from core queues. In production this group is idle until `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` is set after external sandbox signoff. |
Source of truth: `packages/queue/src/groups.ts`. A test
(`groups.test.ts`) fails CI if a queue exists that isn't mapped to a
group — adding a new package queue forces a deliberate group assignment.
Outbox and auxiliary subscriber queues are wired by `apps/worker/src/index.ts`
through their dedicated bootstrap modules; update this table when adding a
new subscriber that is not represented in `packages/queue`.
`outbox.workflow` runs on the `general` group. The worker performs
live-workflow lookup and preflight checks in `apps/worker/src/jobs`, then
starts matching workflows through the selected executor: signed internal
workflow-run API by default, or hybrid/native execution when explicitly
configured. The native executor is a credential-free safe-core path: it must
not hydrate stored workflow credentials, and any `$credentials.*`,
`lookup $credentials`, or `#with $credentials` reference is a preflight gap.
That bridge avoids importing `apps/web` server modules into the worker
while keeping the existing workflow execution semantics centralized. The
same receiver also drains `workflow.execution.requested` rows produced by
manual workflow runs and tenant API workflow runs: the web action creates the
`WorkflowExecution` and outbox row in one transaction, then worker-general
starts that existing execution through the signed internal execution-start
bridge or the native safe-core executor. Waiting execution resumes publish
`workflow.execution.continue.requested`; worker-general then resumes the
existing execution through the signed internal continuation bridge or, in
native/hybrid mode, through the worker-native safe-core continuation path when
the workflow's only native preflight gap is the current `waitForInput` boundary.
Unsupported continuation graphs still fall back to the HTTP bridge in `hybrid`
mode and fail loud in `native` mode. Stream runs remain separate until their
progress contract moves to a distributed execution path. The
subscriber/bootstrap path is active from `apps/worker/src/index.ts` when the
`general` worker is configured with the workflow execution settings.
Set `OUTBOX_DISPATCHER_ENABLED=true`, `OUTBOX_DISPATCHER_REQUIRED=true`, and
`WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true` on staging/production `worker-general`
once Redis plus the bridge URL/token are provisioned; in that mode a missing
dispatcher or bridge dependency fails startup instead of leaving outbox rows or
`outbox.workflow` silently undrained behind a green process.
Source-inventory tests guard that legacy `WorkflowEngine.trigger(...)`
call-sites stay absent and that only the signed internal trigger bridge may
start `type: "trigger"` executions directly. The `row.created`, `row.updated`,
and `row.deleted` route-backed and workflow-context producers now write the row
mutation, application event, and `workflow.trigger.event` outbox row in the same
transaction when a request or workflow event context is passed. Other internal
requestless mutations remain eventless by default to avoid worker feedback loops.
`worker-general` also registers the
`workflow-execution-stalled-reconcile` maintenance scheduler. It repairs only
old manual/API `WorkflowExecution` rows that are still `running`, have no block
runs, have no waiting block, and whose matching
`workflow.execution.requested` outbox event with dispatch key
`workflow-execution:<executionId>` is already `FAILED`. It does not infer
failure from `DONE` outbox rows because `DONE` only proves dispatch into BullMQ,
not workflow completion.
Use `pnpm run verify:workflow-outbox-native` as the canonical non-browser gate
before changing workflow outbox/native execution readiness. The gate composes
existing focused tests for `outbox.workflow` routing, subscriber bootstrap,
native/hybrid safe-core execution, worker health required-mode checks, and
queued manual/API/continuation producer inventory. It does not prove a live
environment is draining Redis jobs; pair it with the opt-in integration test or
operator-gated E2E drain probe for live worker readiness.
Form submissions use a two-hop worker path. The public submit route stores
the `FormSubmission` row and emits `form.submission.process`, which the
outbox dispatcher routes to `outbox.form-submission-route` on
`worker-general`. That subscriber performs tenant-scoped CRM/entity routing,
marks the submission, and publishes follow-up events. Webhook follow-ups use
`webhook.form-submission.deliver`, routed to `outbox.webhook` on
`worker-mail`, where the bridge enqueues the existing `toolctrl.email`
`deliver-webhook` job. This keeps webhook HTTP delivery in the mail/HTTP
throughput group and leaves the general group responsible only for the
database-backed routing decision.
## Env variables per group
Required (per worker container):
| Variable | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `WORKER_QUEUE_GROUP` | `general` / `mail` / `import` / `webhook` / `plugin` |
| `WORKER_HEALTH_PORT` | TCP port for /healthz + /readyz; Dockerfile standalone default is 9100, while compose/Coolify group apps use 9101-9105 |
| `BULLMQ_REDIS_URL` | Redis target |
| `DATABASE_URL` | Postgres (via PgBouncer in staging/production) |
Required only on `worker-general`. The default execution adapter still bridges
through the web runtime; `hybrid` canaries credential-free safe core
event/manual/if/switch/log/alertUser/variable/httpRequest paths, tenant-scoped
rowGet/email blocks, and single-boundary `waitForInput` continuation resumes,
then falls back to the signed HTTP bridge for unsupported blocks or credential
references; `native` is reserved for credential-free safe-core-only deployments
and fails loud for unsupported blocks or credential references.
The signed HTTP bridge requires tenant-scoped workflow candidates; tenantless
platform workflow trigger execution is intentionally unsupported in HTTP mode
and should use native mode once the workflow is within the supported block
contract.
`scripts/coolify-ensure-worker-groups.mjs` verifies these settings on the
Coolify worker apps before deploy: every managed worker group must have
`DATABASE_URL` and `BULLMQ_REDIS_URL` configured, and `worker-general` must
also have either a complete HTTP bridge (`WORKFLOW_TRIGGER_EXECUTOR_URL` /
`APP_URL` / `WEB_ORIGIN` plus `API_ACCESS_TOKEN`) or explicit
`WORKFLOW_TRIGGER_EXECUTOR_MODE=native` / `WORKFLOW_TRIGGER_EXECUTOR_MODE=hybrid`.
| Variable | Purpose |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WORKFLOW_TRIGGER_EXECUTOR_MODE=http` | Explicitly selects the signed HTTP bridge. Use `hybrid` to canary worker-native credential-free safe-core execution with HTTP fallback; use `native` only for the worker-native safe-core MVP (`event`, `manual`, `if`, `switch`, `doNothing`, `log`, `alertUser`, `variable`, `httpRequest`, tenant-scoped `rowGet`/`email`, and single-boundary `waitForInput` continuation resumes). Native/hybrid preflight rejects stored workflow credential references before block execution. |
| `APP_URL`, `WEB_ORIGIN`, or `WORKFLOW_TRIGGER_EXECUTOR_URL` | Web runtime origin used by `outbox.workflow` execution bridge |
| `API_ACCESS_TOKEN` | Canonical service token used by `outbox.workflow`; value must match the web runtime's `API_ACCESS_TOKEN` because the web runtime validates the signed internal bridge against that key |
| `OUTBOX_DISPATCHER_ENABLED=true` | Starts the transactional outbox dispatcher on `worker-general` |
| `OUTBOX_DISPATCHER_REQUIRED=true` | Fail startup if the general worker cannot start the dispatcher |
| `OUTBOX_DONE_RETENTION_HOURS=720` | Daily `worker-general` maintenance keeps DONE outbox rows for 30 days before purging them |
| `OUTBOX_DONE_RETENTION_BATCH_SIZE=1000` | Max DONE outbox rows purged per daily maintenance run; large backlogs drain gradually |
| `WORKFLOW_EXECUTION_STALLED_RECONCILE_OLDER_THAN_MINUTES=30` | Five-minute `worker-general` maintenance marks only untouched manual/API workflow executions as `error` when their initial `workflow.execution.requested` outbox dispatch is already `FAILED` and older than this threshold. |
| `WORKFLOW_EXECUTION_STALLED_RECONCILE_BATCH_SIZE=100` | Max stalled workflow executions inspected per reconciliation run; large incident backlogs drain gradually. |
| `WORKFLOW_TRIGGER_OUTBOX_ENABLED=true` | Keeps the `outbox.workflow` subscriber active; set false only for controlled maintenance |
| `WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY=5` | BullMQ concurrency for workflow trigger fanout |
| `WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true` | Fail startup if the general worker cannot subscribe to `outbox.workflow` |
| `PLUGIN_HOOK_FANOUT_REQUIRED=true` | Optional staging/canary fail-closed switch for `outbox.plugin-hook`; production plugin runtime with `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` already requires fanout when Redis is configured |
## Coolify resources
Coolify must run queue groups as separate persistent applications that use
the same `worker` image with different `WORKER_QUEUE_GROUP` values. The
deploy workflow requires `worker-general`, `worker-mail`,
`worker-import`, `worker-webhook`, and `worker-plugin` resources in the staging and
production resource allow-lists. `worker-mail` is a permanent service, not a
manual workaround: without it, queued transactional mail and mail-backed
outbox/webhook work is not drained after deploy. Without the other group
resources, production traffic falls back into manual starts.
This is an operator-owned runtime prerequisite: the repository and static
verifiers document the required topology, but they do not by themselves prove
that a target Coolify environment currently has every worker group deployed and
healthy.
The same operator/code split applies to plugin runtime status: docs and code
require the `worker-plugin` service, while tenant-facing production execution
remains blocked on external signoff and the explicit
`PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` flip. A live rollout must additionally
pass `pnpm run verify:plugin-runtime-live` in both `worker-general` and
`worker-plugin` so the general fanout/audit path and isolated invoke worker are
checked separately.
Name the resources with the group marker (`worker-general`,
`worker-mail`, `worker-import`, `worker-webhook`, `worker-plugin`) or expose the
`WORKER_QUEUE_GROUP=<group>` marker in the resource metadata returned by
Coolify. Keep `WORKER_ENABLED=true` on each worker resource and assign
distinct health ports (`9101` general, `9102` mail, `9103` import,
`9104` webhook, `9105` plugin). When metrics/admin endpoints are enabled, keep them on
separate ports (`9201` general, `9202` mail, `9203` import, `9204`
webhook, `9205` plugin) so the admin server does not collide with the health server.
`scripts/coolify-ensure-worker-groups.mjs` can create missing worker
applications during the deploy workflow when provisioning is explicitly
enabled. Staging uses:
```bash
COOLIFY_STAGING_PROVISION_WORKER_GROUPS=true
COOLIFY_STAGING_PROJECT_UUID=<project uuid>
COOLIFY_STAGING_SERVER_UUID=<server uuid>
COOLIFY_STAGING_DESTINATION_UUID=<destination uuid>
COOLIFY_STAGING_ENVIRONMENT_NAME=staging
```
Production uses the same keys without `STAGING_`:
`COOLIFY_PROVISION_WORKER_GROUPS`, `COOLIFY_PROJECT_UUID`,
`COOLIFY_SERVER_UUID`, `COOLIFY_DESTINATION_UUID`, and
`COOLIFY_ENVIRONMENT_NAME`. When it creates a new group app, the script
copies the existing worker app env from an allowed non-group worker
resource or from `COOLIFY_STAGING_WORKER_ENV_SOURCE_UUID` /
`COOLIFY_WORKER_ENV_SOURCE_UUID`, then overwrites only the group-specific
settings (`WORKER_ENABLED`, `WORKER_QUEUE_GROUP`, `WORKER_HEALTH_PORT`,
`WORKER_ADMIN_PORT`, and group concurrency). Keep that source worker app
available until every group resource has its own runtime env.
Optional overrides:
| Variable | Default | Notes |
| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WORKER_CONCURRENCY_GENERAL` | 10 | Only read when `WORKER_QUEUE_GROUP=general` |
| `WORKER_CONCURRENCY_MAIL` | 5 | Only read when `WORKER_QUEUE_GROUP=mail` |
| `WORKER_CONCURRENCY_IMPORT` | 1 | Only read when `WORKER_QUEUE_GROUP=import` |
| `WORKER_CONCURRENCY_WEBHOOK` | 20 | Only read when `WORKER_QUEUE_GROUP=webhook` |
| `WORKER_CONCURRENCY_PLUGIN` | 5 | Only read when `WORKER_QUEUE_GROUP=plugin` |
| `WORKER_ADMIN_PORT` | Dockerfile/code default 9101; `9201`-`9205` in Coolify | Must differ from `WORKER_HEALTH_PORT` when `METRICS_ENABLED=true` |
| `WORKER_HEALTH_HOST` | `0.0.0.0` | Override when running outside Docker on multi-NIC hosts |
| `WORKER_REDIS_POOL_SIZE` | 4/4/2/8/4 (general/mail/import/webhook/plugin) | Advisory ioredis pool size |
| `TRANSACTIONAL_MAIL_OUTBOX_REQUIRED` | `false`; managed Coolify `worker-mail` uses `true` | Mail-group only. Set to `true` in staging/production so `worker-mail` exits if the `outbox.mail` bridge cannot subscribe. The Coolify worker-group provisioner sets and drift-checks this for managed `worker-mail` apps. |
## Health endpoints
Every worker exposes two HTTP probes on `WORKER_HEALTH_PORT`:
- `GET /healthz` — liveness. Returns 200 as long as the event loop is
responsive. No external dependency calls; safe to hammer.
- `GET /readyz` — readiness. Runs a Redis ping and a Prisma `SELECT 1`
in parallel with a 2-second timeout each. Returns 200 only when both
succeed; 503 otherwise. Queue-group subscriber probes are also included
for required/expected runtime bridges such as `outbox.mail` on
`WORKER_QUEUE_GROUP=mail`.
Use `/readyz` as the Coolify / Kubernetes readiness probe and `/healthz`
as the liveness probe.
Worker health is primarily a queue-runtime check. For managed `worker-mail`
apps with `TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true`, health diagnostics also
fail loud when the required outbound provider pair `MAILJET_API_KEY` and
`MAILJET_FROM_EMAIL` is absent, using key names only. A healthy
`worker-mail` therefore proves Redis/Postgres reachability, the mail queue
process, the transactional outbox bridge, and required Mailjet env presence;
it still does not prove sender-domain verification or live provider delivery.
Managed Coolify `worker-mail` apps must keep
`TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true` so the process fails fast when
`outbox.mail` cannot subscribe; without that bridge, invite and other
`mail.transactional.*` outbox events can remain pending even if the direct
`toolctrl.email` worker is alive. The Coolify worker-group provisioner writes
and drift-checks this env value for managed mail workers.
Example:
```bash
curl -fsS http://worker-mail:9102/healthz
curl -fsS http://worker-mail:9102/readyz
```
## Connection naming
Each worker tags its ioredis connection with `worker-<group>-<pid>`
(via `apps/worker/src/connection.ts`). Run `CLIENT LIST` against Redis
to attribute traffic to a specific worker group:
```redis
CLIENT LIST
# id=42 name=worker-mail-218 ...
# id=43 name=worker-webhook-219 ...
```
## Scaling cookbook
| Symptom | Action |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Mail queue depth > 1 000 sustained | `docker compose up -d --scale worker-mail=4` |
| Import queue depth > 10 sustained | `docker compose up -d --scale worker-import=2`; review job size first — single jobs taking >10min are usually the real fix |
| AI/browser external-call queue latency p95 climbs | `docker compose up -d --scale worker-webhook=5` (each replica handles 20 parallel) |
| Form-submission webhook or mail outbox latency climbs | `docker compose up -d --scale worker-mail=4`; review SMTP/HTTP provider throttling before raising concurrency further |
| One worker stuck at 100 % CPU | Inspect `/readyz` — if it's still 200, profile inside the process; if 503, look at the `checks` payload to see which dep failed |
## Operator commands
```bash
# Inspect all worker-group logs
docker compose logs -f worker-general worker-mail worker-import worker-webhook worker-plugin
# Restart a single group
docker compose restart worker-import
# Smoke-test all five readiness endpoints
for port in 9101 9102 9103 9104 9105; do
curl -fsS http://localhost:$port/readyz || echo "Worker on $port not ready"
done
```
## Troubleshooting
**Wrong or missing worker group.** The worker Docker image defaults
`WORKER_QUEUE_GROUP` to `general` for raw image users. Coolify group apps
must still set the explicit group marker (`general`, `mail`, `import`,
`webhook`, or `plugin`) so each app drains the intended queues. A missing
Coolify override can therefore start as another `general` worker instead of a
mail/import/webhook/plugin worker; `scripts/coolify-ensure-worker-groups.mjs`
treats that as env drift. An unknown group still fails worker startup when the
worker is enabled. Disabled workers (`WORKER_ENABLED=false`) skip this check so
dev environments without Redis still boot.
**`/readyz` returns 503 with `checks.redis="error"`** — BullMQ couldn't
reach Redis. Confirm `BULLMQ_REDIS_URL` and that the redis container is
healthy. The error is intentionally short (no host names) so the probe
response never leaks credentials.
**`/readyz` returns 503 with `checks.database="error"`** — Prisma
couldn't run `SELECT 1`. Check `DATABASE_URL`, PgBouncer health, and
whether the pool is saturated.
**A queue is silent on every worker** — verify the queue is mapped to a
group in `packages/queue/src/groups.ts`. If you just added a new queue,
the `groups.test.ts` exhaustiveness check should have failed CI; if it
didn't, the queue is missing from `names.ts`.
**Outbox dispatcher missing.** The outbox dispatcher runs on the
`general` group only (see `apps/worker/src/index.ts`). If your stack
runs no `general` replica, outbox events will not dispatch — keep at
least one `general` replica.
## Rollback
Current production worker topology requires an explicit `WORKER_QUEUE_GROUP`
on each Coolify group app whenever `WORKER_ENABLED=true`; there is no
supported `WORKER_QUEUE_GROUP=all` runtime rollback path. The Dockerfile's
standalone default is `general`, so removing the marker during rollback can
silently turn a non-general app into another general worker, while an unknown
group fails startup by design. For an incident rollback, redeploy the previous
known-good image/tag or compose/deploy definition, or keep the five group apps
and roll the affected group back independently. In-flight BullMQ job state
lives in Redis, so jobs are not lost while workers are rolled.