Skip to content

Deploys

main deploys production, beta deploys staging. Never push directly to main — PR through beta.

  1. CI quality gate (.github/workflows/ci.yml) — Biome, typecheck across every workspace, unit tests, the Drizzle migration-ledger check, and audit coverage. It runs on PRs and is called by the build workflow, so the same checks that gate a merge also gate an image.
  2. Build (.github/workflows/build.yml) — images are pushed to registry.tcgkl.com on a self-hosted runner: web, API, WhatsApp gateway and docs. All of them build every run so the moving tags (latest / beta) never drift apart.
  3. Deploy (.github/workflows/deploy-coolify.yml) — POSTs the Coolify webhook, polls the deployment to a terminal state, and gates on the site actually serving a cache-busted 200 from its health path. One retry, then the run fails. The BetterStack monitor is paused around the window.

Deploys — not builds — are gated on which paths changed. A web-only push must not restart the API, because a Coolify compose deploy stops the old containers before starting the new ones.

Changed path Redeploys
apps/web/** web
apps/api/** API
apps/docs/** docs
packages/**, package.json, bun.lock web + API
.github/workflows/** web + API + docs

To force everything without a code change, run the workflow via workflow_dispatch or touch a file under .github/workflows/.

Every Dockerfile builds from the repo root (-f apps/<app>/Dockerfile .) because the workspace lockfile and packages/ live there. The one exception is the WhatsApp gateway, which has its own context.

Coolify’s own auto-deploy must stay off: the workflow pushes the image and triggers the pull, and auto-deploy would redeploy the previous image the moment the branch moves.

The docs are built on the self-hosted runner, like everything else — never on the Coolify host. What differs is what gets shipped: not an image, but the built dist/ itself, committed to a branch that Coolify serves.

flowchart LR
    A[push to main<br/>apps/docs/** changed] --> B[CI quality gate<br/>biome · typecheck · tests]
    B --> C[build-docs<br/>self-hosted runner]
    C --> D[astro build in<br/>oven/bun:1.3.14]
    D --> E[force-replace tree on<br/>branch docs-dist]
    E --> F[Coolify Static build pack<br/>serves docs-dist]
    F --> G[docs.tcgkl.com]

Pushing the branch is the deploy. There is no image, no registry, and no deploy webhook.

Coolify’s Static build pack “packages files already present in the Git repository into an Nginx image” and runs no build command. That is a mismatch for a source branch — it would serve the Markdown — but an exact fit for docs-dist, which contains nothing but the finished site. The build already happened, on the runner, behind the quality gate.

Field Value
Field Value
Repository TCGKL/tcgkl-web
Branch docs-dist
Build Pack / Build strategy Static
Web server nginx:alpine
Base directory /
Watch paths empty
Builder selection Deployment server
Domains https://docs.tcgkl.com
Auto-deploy on

Two fields that look like they want something and do not:

  • Base directory is where the finished site sits inside the branch. docs-dist is nothing but build output — index.html, _astro/, fonts/, pagefind/ all at the top level — so it is /. Pointing it at /docs or /apps/docs/dist looks for a folder the branch does not have.
  • Watch paths stays empty. It filters which changed files trigger a deploy, and every file on this branch is the site, so any push should deploy. The apps/docs/** filter lives on the source side, in build.yml.

Builder selection can stay on the deployment server: the Static strategy compiles nothing, it copies already-built files into the nginx image. The real build happened on the self-hosted runner.

Include the scheme in the domain — that is what triggers the Let’s Encrypt certificate — and point a DNS A record for docs.tcgkl.com at the same server IP the other Coolify apps use.

Auto-deploy on is correct here even though the platform rule says to keep it off for web and API. That rule exists because those apps are deployed by a workflow that pushes an image and then triggers the pull; auto-deploy would race it. Nothing races here — the branch push is the only trigger.

Finally, Customize Your Web Server Configuration: click Generate to load the defaults, replace them with apps/docs/coolify-nginx.conf from this repo, save, then Restart. A redeploy alone does not apply it.

Without it the site still serves, but you lose all four of these:

Rule Why it is there
location = /health Liveness that reads no disk — the same contract as web /api/health and API /health
try_files … =404 + error_page 404 A missing page returns a real 404. The naive fallback serves /404.html with a 200 and search engines index it
/_astro/ + /fonts/ immutable Content-hashed assets and stable font filenames, cached for a year
/pagefind/ 5 min The search index is rebuilt each deploy and is not hashed

Keep the repo copy and the Coolify field in sync — the repo copy is the one that gets reviewed.

Build output, and nothing else. Never edit it by hand, never merge it, never branch off it: every publish replaces the tree wholesale, so a hand-edit disappears on the next docs change. build-docs force-replaces rather than merges precisely so a deleted page actually stops being served.

The job no-ops when the output is byte-identical, so a push that changes only prose in a comment does not produce an empty commit.

A beta push still builds the site — a broken docs build fails the run before it can reach main — but only main publishes. There is one docs environment.

Full history and rationale: docs/deployment-strategy.md.