Skip to content

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.


WhatValue
Coolify UI / API hosthttp://46.225.64.106:8000 (Hetzner box)
Coolify version4.1.2 (v4 API) — upgraded 2026-06-26 from 4.0.0-beta.462
API basehttp://46.225.64.106:8000/api/v1
AuthAuthorization: Bearer $COOLIFY_API_TOKEN

Where the token lives (resolution order, first hit wins — see scripts/lib/coolify.tsresolveCoolifyCreds):

  1. shell env COOLIFY_API_TOKEN
  2. deploy/coolify/coolify.env globals (gitignored)
  3. apps/api/.envcurrently 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.

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.4624.1.2):

  1. Latest stable tag — authoritative: curl -fsSL https://cdn.coollabs.io/coolify/versions.json.coolify.v4.version. Pin to it (don’t float to “latest”).
  2. Back up off-box (all three, then scp down + verify sha256):
    Terminal window
    tar czf coolify-data.tar.gz -C / data/coolify
    cp /data/coolify/source/.env coolify-source-env.bak # the APP_KEY
    docker exec coolify-db pg_dumpall -U coolify | gzip > coolify-db.sql.gz
  3. 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.
  4. Verify: GET /api/v1/version = target · GET /api/v1/applications lists all apps · an app’s /envs returns its keys (proves decryption survived) · app /api/health = ok.
  5. 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.

TargetUUIDBranchBuildNotes
platform (API + dashboard monorepo)yk8c440c0ggkswcwkg0csccomaindockercomposeabeauvois/platform; env via source=compose
viite-ai (Astro static site)how08kc0cgws0scgcsssccssDockerfile, all vars build-timecorporate 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.


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 (the platform target): keys come from docker-compose.coolify.yml ${PLACEHOLDER}s, values from apps/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-ai target): the listed KEY=value lines 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.)

Terminal window
bun run env:coolify:sync # dry-run diff, ALL targets (masked)
bun run env:coolify:sync --target=platform # one target only
bun run env:coolify:sync --apply # write changes + redeploy changed targets
bun run env:coolify:sync --apply --no-deploy # write only; changes apply on next deploy
bun run env:coolify:sync --apply --force # REQUIRED if the remote APP_ENV=production
bun run env:coolify:sync --preview # PREVIEW context — see below

Default (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.

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 --force is not required.
  • With no overlay file, --preview --apply refuses 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 platform app is a Coolify UI setting (app → Advanced → Preview Deployments; the app already carries the default preview_url_template {{pr_id}}.{{domain}}). ⚠ Caveat: the compose file uses network_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.
  1. Add the ${VAR} placeholder to docker-compose.coolify.yml (if new) and the value to apps/api/.env (it’s a source=compose target).
  2. bun run env:coolify:sync --target=platform → read the dry-run diff.
  3. 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.md and the env:example generator. A var absent from the schema is silently dropped from .env.example.

  • Empty values never clobber a populated remote value (so a blank in coolify.env is 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.

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.json lines — 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/** and tasks/** — 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.json above only negates apps/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:

Terminal window
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.

Terminal window
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>.)

The container entrypoint (apps/api/docker-entrypoint.sh) runs before the server starts:

drizzle-kit migrate --config drizzle.migrate.config.ts # applies pending migrations
exec 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_modulesNOT apps/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.
Terminal window
# App status
curl -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).


All under http://46.225.64.106:8000/api/v1, Authorization: Bearer $TOKEN.

ActionCall
VersionGET /version
App detail/statusGET /applications/{uuid}
List env (keys+flags, no values)GET /applications/{uuid}/envs
Bulk upsert envPATCH /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 envDELETE /applications/{uuid}/envs/{envUuid}
Trigger deployPOST /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 are CREATE TABLE IF NOT EXISTS / INSERT … ON CONFLICT DO NOTHING, idempotent.

Steps:

  1. Merge to main. The platform app builds from main; the Phase 0/1/2a commits must land there. (Resolve the worktree branch → main.)
  2. Back up the DB first (deploy-safety gate). Via Adminer or pg_dump on the box; verify it restores into a throwaway DB. Stakes are lower (preview data is semi-disposable) but the migration mutates data — do it.
  3. (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).
  4. Trigger the deployPOST /api/v1/deploy?uuid=yk8c440c0ggkswcwkg0cscco (or UI Redeploy). Boot order: drizzle-kit migratecreate_artifactsbackfill_artifacts (populates from legacy, idempotent, aborts boot on failure) → server starts on artifacts.
  5. Verify: /api/health ok; dashboard /post-drafts + /blog-posts queues and the public blog API show the migrated rows.
  6. 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_TOKEN expires ~weekly (Testing- mode OAuth) and blocks the Gmail ingestion leg of UATs — it does not affect this artifact cutover.