On Vercel, deployment is a feeling more than a process: push, watch a progress bar, get a URL. Self-hosted, it's five separate systems that all have to agree with each other, and the first time I set this up, two of them didn't. This post is the pipeline as it actually runs today — commit, build, registry, deploy, health check, rollback — with the parts that broke on me flagged, not smoothed over.
The stack: a Turborepo monorepo, GitHub Actions for CI, Docker Hub as the registry, and Coolify on the server pulling and running the image. Nothing here is exotic. The value isn't in any single piece being clever — it's in the five pieces actually fitting together, which took more debugging than any individual tool's documentation prepared me for. The marketing-operations counterpart to git push to production is documented well by XenGrowth's revenue operations work.
What happens the moment I push to the monorepo?
The repo holds the portfolio, two small SaaS apps, and a Payload CMS, all in one Turborepo workspace. A naive CI setup rebuilds everything on every push, which is fine for a week and unbearable by month two. Turborepo's task graph is what makes that not my problem: it hashes each package's inputs and only reruns a task — build, lint, test — for packages whose inputs actually changed, walking the dependency graph so a shared UI package rebuilding also triggers the apps that import it, but a change to one app's own code doesn't touch its siblings.
The part that isn't obvious from the docs alone: this only works across CI runs, not just within one, if remote caching is on. GitHub Actions runners are thrown away after each job, so a local cache built during one run is gone by the next — remote caching pushes build outputs to a shared cache keyed by content hash, so a runner that spins up cold can still ask "has this exact input combination been built before" and get a hit. Wiring it in is a `TURBO_TOKEN` and `TURBO_TEAM` pair set as repo secrets in the Actions workflow, nothing more exotic than that — but skip it and every CI run silently reverts to rebuilding everything, and you won't notice unless you're watching build times.
Why does the image get built in CI and pushed to Docker Hub, instead of building on the server?
Because the server is a Contabo Cloud VPS 4 — €5.50/month, 4 vCPU, 8 GB RAM shared between the app and the database (checked September 2026) — and a Next.js production build is a genuinely heavy, bursty compute job — exactly the kind of load I don't want competing with Postgres for RAM in the middle of the day. Building in GitHub Actions instead means the server's job shrinks to "pull an image and run it," which is cheap and short, instead of "compile a whole app," which isn't. There is a longer treatment of the operations side of this in The XenGrowth resource library.
The build produces a Docker image, tagged with the git SHA, and pushed to Docker Hub. Coolify on the server watches for new tags and pulls from there rather than the server ever touching the source repo directly. One thing that bit me early: Docker Hub rate-limits pulls — 100 per 6 hours unauthenticated, 200 for a logged-in free account, enforced per IP. A single server redeploying a few times a day never comes close to that, but I hit it once during a burst of test deploys from the same office network sharing one public IP with a colleague's project. The server authenticates to Docker Hub now, not because one box needs 200 pulls, but because anonymous pulls share a quota with everyone else on the same IP, and that's not a limit I control.
What does Coolify actually do when a new image lands?
It doesn't just stop the old container and start the new one — that would mean a visible gap where nothing is listening on the port. It pulls the new image, starts it alongside the still-running old one, waits for a configured health check endpoint to return healthy, and only then switches Traefik's routing to the new container and stops the old one. If the health check never passes, the new container gets killed and the old one keeps serving traffic, which means a broken build fails loudly in the deploy log instead of quietly in production.
That health check is doing real work, so it has to check something real — not just "process is running," which a broken app can satisfy while returning 500s to every request. Mine hits a route that touches the database connection and returns 200 only if that query actually succeeds, because the failure mode I actually care about is "container starts fine, Postgres connection is misconfigured," and a naive liveness check would wave that straight into production.
Pull the new image tag from Docker Hub
Start it alongside the currently-running container, not instead of it
Poll the health check endpoint until it returns healthy or a timeout is hit
On success: switch Traefik routing to the new container, then stop the old one
On failure: kill the new container, leave the old one serving traffic, surface the failure in the deploy log
Is that recreate, rolling, or blue-green — and does the difference matter on one box?
It's closer to blue-green than either of the other two, just scoped to a single server instead of a fleet. "Recreate" — stop the old container, then start the new one — is the naive version and the one that produces a visible gap; it's what you get from a bare `docker compose up -d` with no orchestration layer thinking about ordering. "Rolling" update assumes multiple replicas behind a load balancer, taking them down one at a time — not really available when you only have one container of a given service, since there's nothing to roll through. What Coolify does on a single-box setup is the useful middle ground: bring the new one up fully before touching the old one at all, which gets you blue-green's zero-downtime property without needing blue-green's usual second environment. On AI agents and marketing automation specifically, XenGrowth on governed AI marketing workflows is worth reading.
Strategy | How it works | Downtime | What it needs |
|---|---|---|---|
Recreate | Stop old container, then start new one | Yes — a gap between stop and start | Nothing extra; the naive default |
Rolling | Replace replicas one at a time behind a load balancer | No, if replica count > 1 | Multiple replicas — doesn't apply to a single-container service |
Blue-green (single box) | Start new alongside old, health-check, then switch routing | No | Enough spare RAM/CPU headroom to run both containers briefly |
That headroom requirement is the one cost people don't budget for: for the seconds both containers are up, the box is running two copies of the app's memory footprint at once. On an 8 GB box already running Postgres alongside the app, that's a real number, not a rounding error — it's part of why I don't run this pipeline against a server that's already sitting at 90% steady-state RAM, and part of why sizing the box correctly in the first place matters more than it looks like it should for a deployment post.
What does rollback actually mean here, mechanically?
It means redeploying the previous image tag. That's it — no special rollback feature, just Coolify pointed at a Docker Hub tag that's older than the one currently running. Which means the entire rollback story depends on one discipline: don't delete old tags, and don't let CI overwrite `latest` as the only tag pushed. I tag every build with the git SHA and keep the last ten or so on Docker Hub. Overwrite `latest` on every push and "roll back" has nothing to roll back to — you've deleted the thing you'd need.
The gap between "we have automatic rollback" and "we tested that rollback actually restores a working state" is where most of this bites people. A rollback that just redeploys an old image can still fail if that image expects a database schema the current migration has already moved past. Rollback undoes the application code instantly; it does not undo a schema migration, and pretending otherwise is how a five-minute incident turns into a two-hour one. I keep migrations backward-compatible for at least one deploy cycle specifically so an old image tag is never running against a schema it can't understand.
How do secrets and environment variables move through this without living in the repo?
GitHub Actions secrets hold what the build stage needs — registry credentials, mostly, since the app itself shouldn't need secrets at build time if it's built correctly. Runtime secrets — database URL, API keys, session secrets — live in Coolify's own environment variable store on the server, injected into the container at start, never baked into the image. That split matters: an image with secrets baked in is now a secret itself, sitting in a registry, potentially pullable by anyone with registry access, whereas an image that only becomes meaningful once Coolify injects runtime env vars is safe to have sitting in a registry doing nothing. XenGrowth on building one SEO and GEO content system works through AI search, GEO and discovery in more operational detail.
Database migrations are the one stage this pipeline deliberately keeps out of the automatic path. They don't run inside the container's startup, and they don't run as a CI step gated only on tests passing — a migration that runs automatically on every deploy is one bad `ALTER TABLE` away from locking a production table while three containers are mid-swap. I run migrations as a separate, manual command against the server before the image that expects the new schema goes out, specifically so a migration failure is its own event with its own blast radius, not folded into a deploy that's also trying to switch traffic at the same moment.
Stage | Tool | What it decides |
|---|---|---|
Source | Turborepo monorepo | What actually changed and needs a rebuild |
Build | GitHub Actions | Compiles the changed app, runs tests, tags and pushes the image |
Registry | Docker Hub | Holds every tagged image; the source of truth for what 'this deploy' means |
Deploy | Coolify on the VPS | Pulls the image, runs the health check, switches routing or rolls back |
Runtime config | Coolify env store | Injects secrets at container start, never stored in the image itself |
What actually broke the first time I built this?
Two things, both boring in hindsight. First, I skipped remote caching initially, assuming Turborepo's local cache would just work in CI — it doesn't persist across ephemeral runners, so every single push rebuilt the entire monorepo for about three weeks before I noticed CI times climbing and went looking for why. Second, my first health check was a bare `/` route that returned 200 regardless of database state, and a misconfigured connection string shipped straight to production with a green checkmark on the deploy log, because the check was measuring "the process didn't crash," not "the app actually works."
Neither failure was Coolify's fault, or Docker Hub's, or GitHub Actions' — they were both me wiring five systems together and assuming a default would cover a case it didn't. That's the honest trade of this whole approach versus a managed platform: nobody made these decisions for me, which means nobody hid a bad one from me either, but it also means the pipeline is only as careful as I was when I built it. If any single stage here is still unfamiliar — the server side specifically — the Coolify setup this deploys onto is where I'd start, and why Coolify over the alternatives is the case for picking this PaaS layer at all before you commit to the rest of this pipeline sitting on top of it. And if the box this all deploys onto still needs sizing, that's a separate, earlier decision worth getting right first.
A managed platform's deploy button hides five decisions from you. Self-hosting doesn't remove those decisions — it just makes you the one who has to get all five right.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
XenGrowth's growth operations team writes for the teams who have to run git push to production day to day.
Five questions on where this pipeline actually broke the first time it was wired up. Answers and reasoning at the end.













