Deploys
main deploys production, beta deploys staging. Never push directly to
main — PR through beta.
The pipeline
Section titled “The pipeline”- 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. - Build (
.github/workflows/build.yml) — images are pushed toregistry.tcgkl.comon 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. - 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 site specifically
Section titled “The docs site specifically”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.
Why the Static build pack is right here
Section titled “Why the Static build pack is right here”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.
Coolify settings
Section titled “Coolify settings”| 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-distis nothing but build output —index.html,_astro/,fonts/,pagefind/all at the top level — so it is/. Pointing it at/docsor/apps/docs/distlooks 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, inbuild.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.
What the nginx config buys
Section titled “What the nginx config buys”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.
The docs-dist branch
Section titled “The docs-dist branch”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.
