# Deployment: boiler.saas-ctrl.com Reference for the public boilerplate-demo deployment. The values below are **what to configure in Coolify**, not what to commit. Real secrets (`SESSION_SECRET`, `CRYPTO_SECRET`, etc.) are generated once and live only in Coolify's encrypted env store and the optional `.env` on the host. ## Hostnames Single web-facing host with split admin/api subdomains so the cookie-scope helper in `@toolctrl/config` can still produce canonical URLs. | Var | Value | | ----------------- | ---------------------------------------------------------------- | | `APP_BASE_DOMAIN` | `saas-ctrl.com` | | `WEB_APP_HOST` | `boiler.saas-ctrl.com` | | `ADMIN_APP_HOST` | `admin-boiler.saas-ctrl.com` | | `API_HOST` | `api-boiler.saas-ctrl.com` | | `APP_PROTOCOL` | `https` | | `PORT` | `8080` (Dockerfile default; Coolify routes 443 → container 8080) | | `API_PORT` | `8080` (api service runs separately if api host is wired) | DNS records (A or CNAME to the Coolify ingress IP): - `boiler.saas-ctrl.com` - `admin-boiler.saas-ctrl.com` - `api-boiler.saas-ctrl.com` For a single-host minimal demo the admin/api hosts can resolve to the same IP — the runtime checks the `Host:` header to pick the surface. ## Required core secrets Generate once with `openssl rand -hex 32` and store in Coolify: - `SESSION_SECRET` — cookie signing - `CRYPTO_SECRET` — AES key for stored workflow credentials (separate from SESSION_SECRET) - `API_ACCESS_TOKEN` — `openssl rand -base64 32` - `JWT_SECRET` — `openssl rand -hex 32` - `ANALYTICS_TOKEN` — `openssl rand -hex 24` ## Brand `BRAND_*` overrides set the public face. For the demo: ``` BRAND_PRODUCT_NAME=ToolCTRL Boilerplate BRAND_SITE_URL=https://boiler.saas-ctrl.com BRAND_SUPPORT_EMAIL=info@tool-ctrl.de BRAND_SUPPORT_FROM_NAME=ToolCTRL Boilerplate BRAND_LEGAL_IMPRINT_URL=https://saas-ctrl.com/impressum BRAND_LEGAL_PRIVACY_URL=https://saas-ctrl.com/datenschutz BRAND_LEGAL_TERMS_URL=https://saas-ctrl.com/agb ``` ## Database Provision via Coolify's managed Postgres (or external): - `DATABASE_URL` — `` for application/runtime traffic - `DIRECT_URL` — optional `` for migrations against the primary DB ## Mail (optional but expected for boilerplate demo) ``` MAILJET_API_KEY= MAILJET_FROM_EMAIL=noreply@saas-ctrl.com # Deployment policy/evidence marker only; the mail provider does not read it. MAILJET_SECRET_KEY= MAILJET_WEBHOOK_SECRET= ``` The managed `worker-mail` Coolify readiness check verifies that the mail queue runtime can boot, reach Redis/Postgres, and, when fail-loud mail mode is enabled, see the required Mailjet environment variables. Treat actual transactional mail delivery as ready only after sender-domain/provider verification and a real delivery smoke have passed. ## Seed (production bootstrap) ``` SEED_ADMIN_EMAIL=admin@saas-ctrl.com SEED_ADMIN_PASSWORD= SEED_DEMO_DATA=false NODE_ENV=production ``` ## Storage (optional) If file uploads should work in the demo: ``` STORAGE_ENDPOINT=https://s3.eu-central-1.wasabisys.com STORAGE_REGION=eu-central-1 STORAGE_BUCKET=boiler-saas-ctrl-com-uploads STORAGE_ACCESS_KEY_ID= STORAGE_SECRET_ACCESS_KEY= STORAGE_PUBLIC_BASE_URL=https://uploads.boiler.saas-ctrl.com STORAGE_FORCE_PATH_STYLE=false ``` ## Workers The active Gitea/Coolify deploy path expects separate worker applications for general, mail, import, webhook, and plugin queue groups. Create five persistent Coolify worker applications from the same worker image. They are internal runtime apps only: do not configure public FQDNs for any worker app. This deployment-specific table is a summary. The canonical worker topology, queue ownership, concurrency, and scaling guide is [Worker queue groups](../ops/worker-queues.md). | Coolify app | Required group | Health port | Admin/metrics port | | ---------------- | ---------------------------- | ----------- | ------------------ | | `worker-general` | `WORKER_QUEUE_GROUP=general` | `9101` | `9201` | | `worker-mail` | `WORKER_QUEUE_GROUP=mail` | `9102` | `9202` | | `worker-import` | `WORKER_QUEUE_GROUP=import` | `9103` | `9203` | | `worker-webhook` | `WORKER_QUEUE_GROUP=webhook` | `9104` | `9204` | | `worker-plugin` | `WORKER_QUEUE_GROUP=plugin` | `9105` | `9205` | Keep all five services present in staging and production-like environments. `worker-mail` is a permanent service for transactional mail and mail-backed outbox/webhook work; it must not be treated as a manual workaround started only after mail backlog appears. `worker-plugin` is also permanent topology, but production plugin execution remains blocked until external WASM sandbox signoff and an explicit `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` rollout. Before that flip, a healthy `worker-plugin` only proves the idle service can boot; it is not tenant plugin runtime approval. During the rollout, run `pnpm run verify:plugin-runtime-live` inside both `worker-general` and `worker-plugin` before enabling tenant plugins. `worker-import` is also permanent topology but stays idle until import/export processing is fully wired. Leave `IMPORT_QUEUE_ENABLED` and `IMPORT_QUEUE_RUNTIME_READY` unset on `worker-import` until the worker bootstrap injects import, export, and scan services; the service may then report healthy with an empty queue list instead of consuming jobs it cannot process. Each worker needs the same database and queue connection, plus its own queue group/ports: ``` WORKER_ENABLED=true DATABASE_URL= BULLMQ_REDIS_URL= WORKER_QUEUE_GROUP= WORKER_HEALTH_PORT=<9101|9102|9103|9104|9105> WORKER_ADMIN_PORT=<9201|9202|9203|9204|9205> ``` `EXPORT_RETENTION_QUEUE_ENABLED=true` is a legacy standalone-worker fallback. Do not set it as part of the Coolify queue-group contract; when `WORKER_QUEUE_GROUP` is valid, export-retention ownership comes from `packages/queue/src/groups.ts`. Add these only on `worker-general` in staging/production so outbox dispatch and workflow trigger fanout fail loud instead of appearing healthy while rows pile up: ``` OUTBOX_DISPATCHER_ENABLED=true OUTBOX_DISPATCHER_REQUIRED=true WORKFLOW_TRIGGER_OUTBOX_ENABLED=true WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true WORKFLOW_TRIGGER_EXECUTOR_MODE=http WORKFLOW_TRIGGER_EXECUTOR_URL= # or APP_URL/WEB_ORIGIN= API_ACCESS_TOKEN= ``` Keep this unset or false on `worker-mail` until seed data, persisted stats aggregation, Playwright E2E, Mailjet delivery evidence/reconciliation, scheduler/deploy evidence, and the deployment record approve production delivery. The bundled renderer and `@toolctrl/mail` Mailjet adapter exist, but the sequence subscribers do no work unless the value is explicitly `true`; stale-lead/trial-expiring cron runner functions are not production scheduler-registered yet: ``` MARKETING_SEQUENCES_ENABLED=false ``` Add this only on `worker-mail` in staging/production so transactional mail outbox bootstrap fails loud when Redis, database, or mail-outbox wiring is not ready. The worker-group provisioner sets and drift-checks this value for managed `worker-mail` Coolify applications: ``` TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true ``` `scripts/coolify-ensure-worker-groups.mjs` treats missing `DATABASE_URL` or `BULLMQ_REDIS_URL` on any managed worker group (`worker-general`, `worker-mail`, `worker-import`, `worker-webhook`, `worker-plugin`) as deployment drift. It also treats missing `worker-general` workflow-trigger bridge configuration (`WORKFLOW_TRIGGER_EXECUTOR_URL`, `APP_URL`, or `WEB_ORIGIN` plus `API_ACCESS_TOKEN`) and missing mail outbox fail-loud configuration as drift. If `WORKFLOW_TRIGGER_EXECUTOR_MODE=native` is selected instead of `http`, keep that choice explicit in Coolify and verify the workflow graph uses only the worker-native safe-core blocks. Use `WORKFLOW_TRIGGER_EXECUTOR_MODE=hybrid` to canary native safe-core execution while routing unsupported workflow graphs through the signed HTTP bridge. Coolify worker readiness checks should target `/readyz` on `WORKER_HEALTH_PORT`; `/healthz` remains the lightweight liveness probe for manual inspection. This confirms queue/runtime readiness for the selected worker group, including Redis and database reachability. In fail-loud mail mode, managed `worker-mail` readiness also checks the required Mailjet env presence. It still does not prove sender-domain verification or live provider delivery. Admin/metrics endpoints, when enabled, must remain on `WORKER_ADMIN_PORT` and must not be exposed publicly. ## Observability (opt-in) ``` SENTRY_DSN= SENTRY_ENVIRONMENT=production # OTEL_EXPORTER_OTLP_ENDPOINT=http://otel.internal:4318/v1/traces OTEL_SERVICE_NAME=boiler-saas-ctrl-com ``` ## Coolify configuration checklist 1. Resource type: Docker Image applications for the published web, api, and worker images. 2. Source: this Gitea repository, `main` branch. 3. Domain: `boiler.saas-ctrl.com` (Coolify handles Let's Encrypt). 4. Port: container `8080` → public `443`. 5. Healthcheck: web/api use `GET /healthz`; worker apps use `GET /readyz` on `WORKER_HEALTH_PORT`. Container HEALTHCHECK directives cover liveness; Coolify readiness should wait for the service-specific endpoint. 6. Web resource command slots: `pre_deployment_command` runs `cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts`; `post_deployment_command` runs `cd /myapp && NODE_ENV=production node packages/db/dist/production-seed.server.js` (staging adds `SEED_DEMO_DATA=true ALLOW_STAGING_DEMO_SEED=true`). Do not combine the seed into the pre-deploy slot. 7. Env: paste the block above. 8. Worker apps: create `worker-general`, `worker-mail`, `worker-import`, `worker-webhook`, and `worker-plugin` as separate persistent Coolify applications with no public FQDN. Set `WORKER_QUEUE_GROUP`, `WORKER_HEALTH_PORT`, and `WORKER_ADMIN_PORT` per the worker table, and point the Coolify health check at `/readyz`. Do not rely on the worker image's standalone `WORKER_QUEUE_GROUP=general` default for grouped Coolify apps; missing per-app overrides make non-general apps drain the wrong queues. 9. Mail delivery: after `worker-mail` is healthy, verify `MAILJET_API_KEY` and `MAILJET_FROM_EMAIL` exist on the mail-capable runtime and run a real transactional-mail smoke. A green worker health check alone is not mail-provider readiness. ## Trigger a deploy - Staging: push to `main` builds and pushes images in Gitea CI, patches the staging Coolify image tags, then triggers staging deploys. - Production: use the Gitea `workflow_dispatch` promote action with a reviewed `staged_sha` and `backup_confirmed=true`. Promotion retags the verified staging images; do not treat a plain `main` push as a production deploy. - Manual emergency deploy: Coolify dashboard → resource → **Deploy**, only after image tags and migration command state are verified against the runbook. ## Verify the deploy After Coolify reports "healthy": ```sh curl -fsS https://boiler.saas-ctrl.com/healthz curl -fsS https://api-boiler.saas-ctrl.com/healthz # Image-signing — optional, with cosign installed: cosign verify --key cosign.pub {registry}/{namespace}/web: ``` If web/API `/healthz` returns the expected `revision`, verify Coolify also reports `worker-general`, `worker-mail`, `worker-import`, `worker-webhook`, and `worker-plugin` healthy. The Gitea deploy workflow must also show that each worker passed Coolify execute-based `node apps/worker/dist/health.js` readiness with the expected `revision`, `WORKER_QUEUE_GROUP`, and queue list. `worker-import` may have an empty queue list while its import runtime gates are unset; if it reports `toolctrl.imports` or `toolctrl.exports`, both import gates and service wiring must be intentional. Image alias/tag output is configuration evidence only; it is not an acceptable fallback for worker health. If Coolify execute is unavailable, empty, or non-JSON, fix execute access and rerun the workflow before accepting the worker rollout. Then log into `boiler.saas-ctrl.com` with the seeded admin credentials and confirm the tenant switcher loads. ## Current deploy latency levers Gitea CI pre-builds immutable `web`, `api`, and `worker` images and Coolify deploys those published tags. Production promotion retags the verified staging images and does **not** rebuild them. Known remaining latency levers: - Docker target builds are already launched in parallel with PID aggregation and per-target cache refs; remaining CI-side gains likely come from cache warmup, runner CPU/network capacity, or splitting buildx work across dedicated runners. - Registry cache is already enabled through `--cache-from/--cache-to` with stable target-specific cache refs, so cache misses usually mean a cold registry cache or Dockerfile layer invalidation. - Coolify pre-pull is best-effort and bounded by the helper timeout; a slow host can still spend time pulling layers during deploy. - Staging and production dependent resources are deployed separately so web health gates worker/API rollout; this is safer, but not the fastest possible rollout. - Optional post-deploy throughput probes remain in `scripts/run-load-tests.mjs`.