14 KiB
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.comadmin-boiler.saas-ctrl.comapi-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 signingCRYPTO_SECRET— AES key for stored workflow credentials (separate from SESSION_SECRET)API_ACCESS_TOKEN—openssl rand -base64 32JWT_SECRET—openssl rand -hex 32ANALYTICS_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—<pooled-postgres-url>for application/runtime trafficDIRECT_URL— optional<direct-postgres-url>for migrations against the primary DB
Mail (optional but expected for boilerplate demo)
MAILJET_API_KEY=<from Mailjet>
MAILJET_FROM_EMAIL=noreply@saas-ctrl.com
# Deployment policy/evidence marker only; the mail provider does not read it.
MAILJET_SECRET_KEY=<mailjet-secret-key>
MAILJET_WEBHOOK_SECRET=<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=<one-time bootstrap; rotate on first login>
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=<from Wasabi>
STORAGE_SECRET_ACCESS_KEY=<from Wasabi>
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.
| 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=<pooled-postgres-url>
BULLMQ_REDIS_URL=<redacted-redis-url>
WORKER_QUEUE_GROUP=<general|mail|import|webhook|plugin>
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=<app-origin> # or APP_URL/WEB_ORIGIN=<app-origin>
API_ACCESS_TOKEN=<same value as web 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=<from Sentry>
SENTRY_ENVIRONMENT=production
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel.internal:4318/v1/traces
OTEL_SERVICE_NAME=boiler-saas-ctrl-com
Coolify configuration checklist
- Resource type: Docker Image applications for the published web, api, and worker images.
- Source: this Gitea repository,
mainbranch. - Domain:
boiler.saas-ctrl.com(Coolify handles Let's Encrypt). - Port: container
8080→ public443. - Healthcheck: web/api use
GET /healthz; worker apps useGET /readyzonWORKER_HEALTH_PORT. Container HEALTHCHECK directives cover liveness; Coolify readiness should wait for the service-specific endpoint. - Web resource command slots:
pre_deployment_commandrunscd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts;post_deployment_commandrunscd /myapp && NODE_ENV=production node packages/db/dist/production-seed.server.js(staging addsSEED_DEMO_DATA=true ALLOW_STAGING_DEMO_SEED=true). Do not combine the seed into the pre-deploy slot. - Env: paste the block above.
- Worker apps: create
worker-general,worker-mail,worker-import,worker-webhook, andworker-pluginas separate persistent Coolify applications with no public FQDN. SetWORKER_QUEUE_GROUP,WORKER_HEALTH_PORT, andWORKER_ADMIN_PORTper the worker table, and point the Coolify health check at/readyz. Do not rely on the worker image's standaloneWORKER_QUEUE_GROUP=generaldefault for grouped Coolify apps; missing per-app overrides make non-general apps drain the wrong queues. - Mail delivery: after
worker-mailis healthy, verifyMAILJET_API_KEYandMAILJET_FROM_EMAILexist 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
mainbuilds and pushes images in Gitea CI, patches the staging Coolify image tags, then triggers staging deploys. - Production: use the Gitea
workflow_dispatchpromote action with a reviewedstaged_shaandbackup_confirmed=true. Promotion retags the verified staging images; do not treat a plainmainpush 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":
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:<sha>
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-towith 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.