Coolify operations runbook
How env vars and deploys flow to the Coolify instance that hosts the platform.
Covers the env-sync process, the deploy process, the direct API, and
the Phase 2a artifact-migration cutover. Pairs with HETZNER.md
(the box), ENV_VARS.md (the var inventory), and
SECRETS.md.
There is no separate prod — this Coolify instance is the live/preview environment. Treat it accordingly.
1. The instance & credentials
Section titled “1. The instance & credentials”| What | Value |
|---|---|
| Coolify UI / API host | http://46.225.64.106:8000 (Hetzner box) |
| Coolify version | 4.1.2 (v4 API) — upgraded 2026-06-26 from 4.0.0-beta.462 |
| API base | http://46.225.64.106:8000/api/v1 |
| Auth | Authorization: Bearer $COOLIFY_API_TOKEN |
Where the token lives (resolution order, first hit wins — see
scripts/lib/coolify.ts → resolveCoolifyCreds):
- shell env
COOLIFY_API_TOKEN deploy/coolify/coolify.envglobals (gitignored)apps/api/.env← currently here (COOLIFY_API_TOKEN, len 50)
COOLIFY_BASE_URL resolves the same way; it’s set in deploy/coolify/coolify.env.
Never commit the token — deploy/coolify/coolify.env is gitignored; only
coolify.env.example is tracked.
Upgrading the Coolify version
Section titled “Upgrading the Coolify version”Coolify runs as a control plane on the API box; its own state lives in
/data/coolify + the coolify-db Postgres container. The app databases are on a
separate server (10.0.1.20) and are untouched by a control-plane upgrade. The
single most important file is /data/coolify/source/.env — its APP_KEY decrypts
every stored env var; lose it and all encrypted config is unrecoverable.
Runbook (last run 2026-06-26, 4.0.0-beta.462 → 4.1.2):
- Latest stable tag — authoritative:
curl -fsSL https://cdn.coollabs.io/coolify/versions.json→.coolify.v4.version. Pin to it (don’t float to “latest”). - Back up off-box (all three, then
scpdown + verify sha256):Terminal window tar czf coolify-data.tar.gz -C / data/coolifycp /data/coolify/source/.env coolify-source-env.bak # the APP_KEYdocker exec coolify-db pg_dumpall -U coolify | gzip > coolify-db.sql.gz - Upgrade in place (pinned):
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash -s <version>— pulls images,docker compose up -d, runs Coolify’s migrations. ~1–3 min UI outage; deployed app containers keep serving. - Verify:
GET /api/v1/version= target ·GET /api/v1/applicationslists all apps · an app’s/envsreturns its keys (proves decryption survived) · app/api/health=ok. - Rollback: restore
/data/coolify+ the pg dump, re-pin the previous version,docker compose up -d. Whole-box fallback: a Hetzner machine snapshot taken before step 3.
Full phased plan: tasks/coolify-upgrade-rc4.md.
Deploy targets
Section titled “Deploy targets”| Target | UUID | Branch | Build | Notes |
|---|---|---|---|---|
| platform (API + dashboard monorepo) | yk8c440c0ggkswcwkg0cscco | main | dockercompose | abeauvois/platform; env via source=compose |
| viite-ai (Astro static site) | how08kc0cgws0scgcsssccss | — | Dockerfile, all vars build-time | corporate site; reads published blog at build |
The platform app builds from
main. Anything on a feature/worktree branch (incl. the Phase 0/1/2a artifact work) is not deployable until merged to main.
2. The env process
Section titled “2. The env process”Source of truth
Section titled “Source of truth”deploy/coolify/coolify.env (gitignored) is the single authoritative
declaration of every var pushed to every target. Format is documented in
deploy/coolify/coolify.env.example. Two section styles:
source=compose(theplatformtarget): keys come fromdocker-compose.coolify.yml${PLACEHOLDER}s, values fromapps/api/.env. No inline values needed — this avoids duplicating platform secrets. Create/update only, never deletes, and never touches env-specific keys Coolify owns.- inline (the
viite-aitarget): the listedKEY=valuelines are the complete desired set — the sync reconciles remote to match exactly (creates, updates, and deletes remote extras).
Env-specific keys Coolify owns (never pushed by a compose target — local
values are wrong for the server): NODE_ENV, APP_ENV, *_URL, *_URLS,
AUTH_SECRET/BETTER_AUTH_SECRET, DATABASE_URL, CHROME_CDP_ENDPOINT,
SOURCE_COMMIT, X_OAUTH_REDIRECT_URI, anything ending _PORT.
(See ENV_SPECIFIC in scripts/lib/coolify.ts.)
Commands (bun run env:coolify:sync)
Section titled “Commands (bun run env:coolify:sync)”bun run env:coolify:sync # dry-run diff, ALL targets (masked)bun run env:coolify:sync --target=platform # one target onlybun run env:coolify:sync --apply # write changes + redeploy changed targetsbun run env:coolify:sync --apply --no-deploy # write only; changes apply on next deploybun run env:coolify:sync --apply --force # REQUIRED if the remote APP_ENV=productionbun run env:coolify:sync --preview # PREVIEW context — see belowDefault (no flags) is a safe masked dry-run — always run it first and read the
diff. --apply redeploys each target it changed (so new env actually takes
effect); add --no-deploy to decouple.
Preview context (--preview)
Section titled “Preview context (--preview)”Coolify stores every env var once per deployment context (is_preview —
production vs PR-preview deployments; that’s why keys appear ×2 in the API).
--preview reconciles the preview context: the desired set is the prod
desired-set with the overrides from deploy/coolify/coolify.preview.env
(gitignored — copy the committed .example) applied on top. “Based on prod,
but different”: Stripe test-mode keys, a dedicated preview DATABASE_URL,
the preview domain, SUBSCRIPTION_PAYWALL_ENABLED=true, …
- Overlay keys bypass the env-specific skip on purpose — the preview values
of
DATABASE_URL& friends are exactly what the overlay declares. Env-specific keys not overlaid are never pushed to preview. - Structurally prod-safe: reads/deletes are filtered to preview entries, the bulk
PATCH sends
is_preview:true, and no redeploy is triggered (PR previews pick the vars up on their next build) — so--forceis not required. - With no overlay file,
--preview --applyrefuses to run: mirroring prod values (live Stripe key…) into preview is exactly the mistake it exists to prevent. - Enabling actual PR preview deployments for the
platformapp is a Coolify UI setting (app → Advanced → Preview Deployments; the app already carries the defaultpreview_url_template{{pr_id}}.{{domain}}). ⚠ Caveat: the compose file usesnetwork_mode: host+ fixed host ports, so a preview container on the same server conflicts with prod’s ports — previews need port/domain overrides before the toggle is useful. The env context above is independent of the toggle and can be provisioned first.
How to add / change a platform env var
Section titled “How to add / change a platform env var”- Add the
${VAR}placeholder todocker-compose.coolify.yml(if new) and the value toapps/api/.env(it’s asource=composetarget). bun run env:coolify:sync --target=platform→ read the dry-run diff.bun run env:coolify:sync --target=platform --apply→ push + redeploy.
Adding a brand-new var also means declaring it in the env schema — see
ENV_VARS.mdand theenv:examplegenerator. A var absent from the schema is silently dropped from.env.example.
Env gotchas (learned the hard way)
Section titled “Env gotchas (learned the hard way)”- Empty values never clobber a populated remote value (so a blank in
coolify.envis a no-op, not a wipe). - GET returns NO values — Coolify’s env list API gives keys + flags only, never the secret values. So the sync can’t “read back” a value; an empty remote reads as absent.
- The same key can exist twice remotely — the sync collapses duplicates.
- viite-ai vars are build-time → changing one requires a rebuild/redeploy, not just an env write.
3. The deploy process
Section titled “3. The deploy process”Auto-deploy IS on for the platform app (Coolify GitHub App, source_type = GithubApp, watching branch main): every push to main triggers a build +
deploy. You can also trigger one explicitly — by the API, the sync script
(--apply redeploys changed targets), or the Coolify UI “Redeploy” button.
Deploy exactly once per merge (watch_paths)
Section titled “Deploy exactly once per merge (watch_paths)”After a PR merges, the Release workflow pushes a second commit —
chore: bump version … [skip ci] (it git adds only package.json,
packages/*/package.json, apps/*/package.json). Coolify (4.1.2) does not
honor [skip ci] in commit messages, so without a filter that bump push would
trigger a second, redundant deploy. (The release tag doesn’t — Coolify
watches branch main, not tags.)
The fix is a Coolify watch_paths filter — gitignore-style, “last match wins”.
It starts from ** (deploy on anything) and subtracts the paths that must not
trigger a platform build, for two different reasons:
**!package.json!packages/*/package.json!apps/*/package.json!.claude/**!tasks/**!apps/developer-website/**!apps/asso/**- The three
package.jsonlines — deploy exactly once per merge. A real merge always changes other files (source,bun.lock, docs) → still deploys; a bump-only push matches every negation → skipped. .claude/**andtasks/**— nothing to deploy. Claude Code commands and plan docs never reach the container, so a build would replace it with an identical image and restart prod for no reason.apps/developer-website/**— it deploys itself. The docs site is its own Coolify app (swwkwo00w8w8wgk0cccc0ws4,base_directory: /apps/developer-website,watch_paths: apps/developer-website/**→ developer.viite.ai). Without this negation a docs change built both apps. Excluding it here removes only the redundant platform build — the docs site still auto-deploys exactly as before.apps/asso/**— same story. asso.viite.ai is its own Coolify app (uyp2g9ein7tpwwu2xgonlics,base_directory: /apps/asso,watch_paths: apps/asso/**→ asso.viite.ai). It was added after this filter and originally missing here, so an asso-only merge built both the asso app (correct) and the platform API (wasteful no-op). This negation removes the redundant platform build; the asso app still auto-deploys itself. Note!apps/*/package.jsonabove only negatesapps/asso/package.json, not the whole dir — the**glob is what excludes the rest of the app, so this line is required, not redundant.
Note the default is deploy: ** means any new top-level directory triggers
a platform build until it’s negated here. Add a line when you add a path that
ships nothing.
This lives only in Coolify state — not in coolify.env, which declares env vars,
not app config — so re-apply it if the app is ever recreated, or the double
builds come back silently:
bun run coolify api PATCH /applications/yk8c440c0ggkswcwkg0cscco \ '{"watch_paths":"**\n!package.json\n!packages/*/package.json\n!apps/*/package.json\n!.claude/**\n!tasks/**\n!apps/developer-website/**\n!apps/asso/**"}'Read the live value back with
bun run coolify api GET /applications/yk8c440c0ggkswcwkg0cscco.
Trigger a deploy via the API
Section titled “Trigger a deploy via the API”TOKEN=$(grep -E '^COOLIFY_API_TOKEN=' apps/api/.env | cut -d= -f2-)BASE=http://46.225.64.106:8000
# Deploy the platform app (pulls the latest commit on `main`, rebuilds, restarts)curl -s -X POST -H "Authorization: Bearer $TOKEN" \ "$BASE/api/v1/deploy?uuid=yk8c440c0ggkswcwkg0cscco"(This is exactly what client.redeploy(uuid) in scripts/lib/coolify.ts calls:
POST /api/v1/deploy?uuid=<uuid>.)
What happens on boot (the migration seam)
Section titled “What happens on boot (the migration seam)”The container entrypoint (apps/api/docker-entrypoint.sh) runs before the
server starts:
drizzle-kit migrate --config drizzle.migrate.config.ts # applies pending migrationsexec bun run ./dist/index.js # then starts the API- Migrations run here (not CI) because only the container reaches the private
DB. A failed migration aborts boot (
set -e) → the deploy health check fails → Coolify keeps the previous container. So a broken migration can’t serve against a half-migrated schema. - The release image ships only
dist+ the migrations folder +node_modules— NOTapps/api/scripts/or app TS source. So TS one-shot scripts (e.g.backfill-artifacts.ts) cannot run in the container; data transforms that must run on Coolify have to be SQL migrations.
Verify / watch a deploy
Section titled “Verify / watch a deploy”# App statuscurl -s -H "Authorization: Bearer $TOKEN" \ "$BASE/api/v1/applications/yk8c440c0ggkswcwkg0cscco" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("status"))'
# App health (served by the API itself)curl -s https://platform-api.viite.ai/api/health # → {"status":"ok","db":"up",...}Or watch build logs in the Coolify UI (Deployments tab).
4. Direct Coolify API quick-reference
Section titled “4. Direct Coolify API quick-reference”All under http://46.225.64.106:8000/api/v1, Authorization: Bearer $TOKEN.
| Action | Call |
|---|---|
| Version | GET /version |
| App detail/status | GET /applications/{uuid} |
| List env (keys+flags, no values) | GET /applications/{uuid}/envs |
| Bulk upsert env | PATCH /applications/{uuid}/envs/bulk — body {data:[{key,value,is_preview:false,is_build_time,is_literal:true}]} (is_literal stops Coolify interpreting $ in values) |
| Delete one env | DELETE /applications/{uuid}/envs/{envUuid} |
| Trigger deploy | POST /deploy?uuid={uuid} |
The Coolify DB itself is reachable via Adminer on the box (see
HETZNER.md) for backups / row-count checks / schema resets.
5. Phase 2a cutover — artifact unification (one-time)
Section titled “5. Phase 2a cutover — artifact unification (one-time)”Ships the unified artifacts table live. The data transfer is a SQL migration
(20260612130000_backfill_artifacts) that auto-runs at boot — before the server
serves the flipped reads — so the table is populated atomically with the cutover
(no read-gap window). Full design: ../../tasks/unified-artifact-model.md.
First deploy carrying this also runs
create_artifacts(P0) — both areCREATE TABLE IF NOT EXISTS/INSERT … ON CONFLICT DO NOTHING, idempotent.
Steps:
- Merge to
main. The platform app builds frommain; the Phase 0/1/2a commits must land there. (Resolve the worktree branch →main.) - Back up the DB first (deploy-safety gate). Via Adminer or
pg_dumpon the box; verify it restores into a throwaway DB. Stakes are lower (preview data is semi-disposable) but the migration mutates data — do it. - (optional pre-flight) run the parity test against a feature DB:
cd apps/api && bun test ./artifact/backfillParity.test.ts(proves the SQL migration == the TS mapping; no drift). - Trigger the deploy —
POST /api/v1/deploy?uuid=yk8c440c0ggkswcwkg0cscco(or UI Redeploy). Boot order:drizzle-kit migrate→create_artifacts→backfill_artifacts(populates from legacy, idempotent, aborts boot on failure) → server starts onartifacts. - Verify:
/api/healthok; dashboard/post-drafts+/blog-postsqueues and the public blog API show the migrated rows. - Rollback if needed: redeploy the previous image — reads flip back to the still-frozen legacy tables; the backfilled artifact rows are harmless. Keep legacy tables ≥1 deploy cycle.
Unrelated heads-up: the preview
GMAIL_REFRESH_TOKENexpires ~weekly (Testing- mode OAuth) and blocks the Gmail ingestion leg of UATs — it does not affect this artifact cutover.
