Git Push to Production: My Self-Hosted Deployment Workflow End to End
Cloud

Git Push to Production: My Self-Hosted Deployment Workflow End to End

No platform button, no black box. A monorepo commit turns into a running container on my own server through Turborepo, GitHub Actions, Docker Hub and Coolify — here's every step, including the ones that broke on me first.

Published August 27, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What actually happens between a git push and a live change on a self-hosted server?

A commit to the monorepo triggers Turborepo to figure out what actually changed, GitHub Actions builds only that, the resulting image goes to Docker Hub, and Coolify on the server pulls it, runs a health check, and either flips traffic to the new container or rolls back automatically. No platform makes any of these decisions for you — every stage is something I configured and can see the logs for, which is the actual trade for giving up Vercel's push-and-forget flow.

  • Turborepo's task graph and caching mean CI builds only the app that changed, not the whole monorepo, every time
  • The build artifact is a container image, pushed to Docker Hub, not code pushed directly to the server
  • Coolify pulls the new image, health-checks it before switching traffic, and keeps the previous container running until it's confident
  • Rollback is 'redeploy the previous image tag,' which only works if you're disciplined about tagging, not about deleting old images

Evidence notes

Docker Hub pull rate limits

100 pulls per 6 hours for unauthenticated requests, 200 for authenticated (free account) — enough reason to have the server authenticate rather than pull anonymously, checked September 2026.

Turborepo remote caching

CI runners are ephemeral, so a local cache dies with the runner; remote caching persists build outputs across runs and is what makes 'only rebuild what changed' actually true in CI, not just locally.

Turborepo + GitHub Actions setup

Official guide for wiring TURBO_TOKEN / TURBO_TEAM into a GitHub Actions workflow so the runner shares cache with local builds.

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

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 systems, all agreeing

Five questions on where this pipeline actually broke the first time it was wired up. Answers and reasoning at the end.

1 / 5
Every push seems to rebuild the entire Turborepo monorepo from scratch in GitHub Actions, even for a one-line change in one package. Why?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

deploymentTurborepoGitHub ActionsDocker HubCoolifyCI/CDself-hostingcloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

My Complete Self-Hosted Stack for SaaS in 2026

Contabo, Coolify, Docker, Cloudflare, Postgres, R2, Resend, Uptime Kuma, Turborepo and Docker Hub. Here's every piece of the stack I actually run, what each one replaced, and why I picked it over the alternatives.

Navigate

Why I Use Turborepo to Build and Deploy to My Own Server

My repo has four apps and three shared packages, and most commits only touch one of them. Rebuilding everything on every push would mean paying compute for work that didn't need doing — Turborepo's whole job here is refusing to do that.

Navigate

Why I Push to Docker Hub Instead of Building on My $10 Server

Build-elsewhere-pull-here isn't a preference, it's the only version of this that doesn't put a compile job in direct competition with the app for the same 8 GB. Here's what that split actually buys, and where a private registry earns its keep instead.

Navigate

Preview Environments Without Vercel: Branch Deploys on Your Own VPS

A preview URL per branch is the one Vercel feature people miss most after leaving. It's buildable on your own server, and it's genuinely harder than Vercel makes it look — mostly because of the database, which nobody's marketing page mentions.

Navigate

The Simplest Production Architecture for a Bootstrapped SaaS

One box, a handful of managed pieces around the edges, and a very short list of things you're not allowed to build yet. Here's the architecture, priced out to $20 a month, and the exact signal that tells you when to add each thing you skipped.

Navigate

Moving a SaaS Off Vercel to a €5.50 VPS: What the Numbers Actually Look Like

Nobody publishes their real Vercel invoice, so most migration posts trade in vibes instead of arithmetic. This one builds the comparison from Vercel's and Contabo's own published rates, states every assumption out loud, and shows exactly where the two lines cross.

Navigate

My Free Monitoring Stack for Self-Hosted Apps

Vercel gives you monitoring whether you ask for it or not. A VPS gives you a blank terminal and the assumption you'll figure it out. Here's what to actually watch on a self-hosted box, with tools that cost nothing, and why watching from the box itself is the one setup that will lie to you.

Navigate

Should Your Database Live on the Same VPS as Your App?

The pitch for co-location is real: no network hop, no egress bill, one box to back up. So is the failure mode — one OOM event takes the app and the database down together, because they were never separate to begin with.

Navigate

Best VPS Providers for Coolify: Contabo, Hetzner and the 2026 Price Shift

Every 'best VPS for Coolify' post still says Hetzner is the cheap option. That stopped being reliably true in June 2026 for two of its most popular tiers, and nobody's updated the recommendation yet.

Navigate

Zero-Downtime Deploys and Instant Rollbacks on a Cheap VPS

docker compose up -d looks like a deploy and behaves like an outage — there's a gap between the old container stopping and the new one answering requests, and on a small box that gap is exactly where a real user lands.

Navigate