Zero-Downtime Deploys and Instant Rollbacks on a Cheap VPS
Cloud

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.

Published September 5, 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 makes a deploy zero-downtime on a single cheap VPS, and why does rollback usually fail on the database, not the app?

Zero downtime means the new container is fully up, health-checked, and taking traffic before the old one stops — never stop-then-start, which always has a gap. That overlap costs real RAM for a few seconds, which is a genuine constraint on a small box, not a rounding error. Rollback is just redeploying an older image tag, mechanically trivial, until a database migration has already run against the new schema — at which point the old code doesn't understand the new tables, and rollback stops being a redeploy and starts being a data problem.

  • docker compose up -d recreates containers in dependency order but still stops the old one before the new one is confirmed healthy — it's not the zero-downtime primitive people assume it is
  • Connection draining means the old container keeps finishing in-flight requests during the handoff instead of dropping them mid-response when it's killed
  • Running two copies of the app briefly costs real memory on an 8 GB box — it's the actual price of zero downtime, not a footnote
  • Rollback undoes application code instantly by pointing at an older tag; it does not undo a schema migration, which is why migrations have to stay backward-compatible for at least one deploy cycle

Evidence notes

Next.js graceful shutdown and after()

Next.js finishes in-flight requests and pending after() callbacks on SIGTERM/SIGINT before exiting; the platform is expected to allow a configurable drain period, 10-30 seconds recommended, before force-killing the process — checked against Next.js 16.3.3's self-hosting docs, September 2026.

Docker Hub pull rate limits

100 pulls per 6 hours unauthenticated, 200 per 6 hours authenticated (free account) — relevant because a rollback is itself a pull of an older tag, and repeated rollback attempts during an incident still count against the same quota, checked September 2026.

Contabo Cloud VPS 4 pricing

4 vCPU, 8 GB RAM, 100 GB SSD, €5.50/month for the first 24 months (promotional, EUR), verified against Contabo's own pricing page, checked September 2026.

Continue with purpose

`docker compose up -d` looks like a deploy and, left to its defaults, behaves like a small outage. It stops the container defined by a changed service, then starts the new one — and between those two steps, nothing is listening on the port. On a small VPS that gap might be a second or two, which sounds negligible until it's the exact second a real request lands and gets a connection refused instead of a response.

None of what follows is exotic engineering — health-gated traffic switches and connection draining are standard practice on any platform doing this correctly. What's specific to a cheap single-box VPS is that every technique here has a resource cost that's easy to hand-wave on a fleet of servers and impossible to ignore on one. Running two containers briefly, keeping a drain window open, retrying a health check a few extra times — all of it is cheap in the abstract and real when there's exactly one 8 GB box paying for it. People arriving at zero-downtime deploys and instant rollbacks on a cheap VPS from a marketing team will find the team at XenGrowth closer to their day.

Why isn't docker compose up -d already zero-downtime?

Because its default recreate behavior is exactly what the name implies: stop the thing that exists, then create the thing that should exist instead. Compose is not wrong to do this — it's a general-purpose orchestration tool, and stop-then-start is the correct, safe default when it has no way to know whether running two versions of a service briefly is safe for that service. For a stateless web app it usually is safe, but Compose doesn't know that on its own, and won't assume it without being told.

Getting zero downtime out of the same primitives means inverting the order: start the new container first, confirm it's actually serving correctly, and only then remove the old one. That's not a Compose feature — it's what Coolify's deploy logic does on top of Compose, and it's achievable by hand with `docker compose up -d --no-deps --scale app=2` style tricks, but by the time you've built that correctly you've rebuilt roughly what Coolify already does for free.

What does the health check actually have to verify before traffic switches?

Something real, not 'the process didn't crash.' A container can start cleanly, bind to its port, and return 200 on a bare `/` route while its database connection string is wrong and every actual request fails with a 500 — a liveness check watching only whether the process is running waves that straight past the gate. The check has to exercise the failure mode that actually matters: a route that touches the database and returns healthy only if that round-trip genuinely succeeds. Anything less is a gate that looks like safety and isn't. The XenGrowth resource library covers the the operations side of this side of this.

  • A bare process check ('is anything listening on the port') passes even when the app can't reach its database

  • A route-based check that returns 200 unconditionally is functionally the same as no check at all

  • A useful check exercises the dependency most likely to be misconfigured after a deploy — usually the database connection, sometimes a required external API

  • The check needs a timeout and a retry budget, not a single attempt — a slow cold start shouldn't be indistinguishable from a broken one

What is connection draining, mechanically, and why does killing a container skip it?

A container that's just `kill`ed (SIGKILL) stops instantly, mid-request if it happens to be mid-request — whoever's connection was open gets a truncated response or nothing at all. Draining means the old container is told to stop accepting new connections but is given a window to finish the ones already in flight before it actually exits. Next.js's own self-hosting behavior does exactly this on a graceful signal: on SIGTERM or SIGINT, it finishes in-flight requests and any pending `after()` callbacks before exiting, and the platform sending that signal is expected to allow a drain period — 10 to 30 seconds is the documented recommendation — rather than force-killing the process immediately. Skip the graceful signal, send SIGKILL straight away instead, and every one of those guarantees disappears; the process dies exactly where it was, request or callback in progress or not.

This is the detail that turns 'zero-downtime' from marketing language into something specific: it isn't only that a new container answers before the old one stops, it's that the old one is allowed to finish what it already started rather than being cut off the instant its replacement passes a health check.

There's a second, less obvious reason the drain period matters: streaming responses. The App Router supports streaming, and a request that's mid-stream when the old container gets killed loses whatever hadn't been flushed yet, not just the parts still being generated — a partial response can be worse for a client than no response at all, because some code paths treat a truncated-but-200 response as success. A drain period gives a streaming response somewhere between ten and thirty seconds to actually finish, which for most page loads is generous, and for the rare slow one is the difference between a clean finish and a silently broken payload. For the AI agents and marketing automation angle, see XenGrowth on governed AI marketing workflows.

Shutdown method

In-flight requests

Typical drain window

SIGKILL (immediate)

Dropped mid-response, no cleanup

None — the process stops instantly

SIGTERM with no drain period configured

Next.js starts graceful shutdown, but the orchestrator may not wait for it to finish

Whatever the orchestrator allows before it escalates to SIGKILL

SIGTERM with a configured drain period

Finished normally, plus pending after() callbacks

10-30 seconds is Next.js's own documented recommendation

What does it actually cost to run two containers at once, even briefly?

Real memory, for the seconds both are alive. Every container running a Next.js app carries its own copy of the Node process, its own module cache, its own connection pool to Postgres — none of that is shared between the old container and the new one just because they're the same app. On a Contabo Cloud VPS 4 (4 vCPU, 8 GB RAM, €5.50/month for the first 24 months as of September 2026) already running Postgres alongside the app, a brief doubling of the app's own memory footprint is a real number against a fixed budget, not a rounding error to wave past. This is exactly why zero-downtime deploys aren't free even when they're mechanically simple: the technique works by trading a few seconds of extra memory pressure for the guarantee that nothing ever goes unanswered, and a box already sitting close to its ceiling doesn't have that few seconds of headroom to spend.

Practically, that means the health check's timeout matters twice over — once for correctness (don't switch traffic to a broken container) and once for cost (don't leave two containers running longer than necessary while a slow health check thinks it over). A short, well-designed check keeps the overlap window tight; a sluggish one keeps the box paying the two-container memory price for longer than the actual deploy needed.

Rollback scenario

What actually happens

Is it safe?

Code-only change, no migration since

Old image pulled, health-checked, traffic switched — same as a normal deploy

Yes — this is the case rollback is designed for

Migration added a column or table, additive only

Old code ignores the new structure it doesn't know about

Yes — additive changes are backward-compatible by nature

Migration renamed or dropped a column the old code reads

Old code queries something that no longer exists and errors

No — this is the case that turns rollback into an incident of its own

Migration changed a column's type or constraint

Old code may write data the new schema can't store, or read it wrong

No — often the most dangerous case, since it can fail silently rather than loudly

So what does rollback actually mean when something goes wrong?

Mechanically, it means pointing the deploy at an older image tag and running the exact same startup sequence — new container up, health-checked, traffic switched — just with 'new' meaning 'the tag that was running before the last one.' There's no separate rollback feature doing anything cleverer than that. Which means the entire rollback story depends on one piece of discipline: tag every build with something durable, like a git SHA, and keep a rolling window of recent tags on the registry rather than letting each push overwrite the same tag. Overwrite the only tag on every push and there's nothing left to roll back to by the time you need it — the previous version was deleted the moment the current one was pushed. XenGrowth on building one SEO and GEO content system approaches this from the AI search, GEO and discovery side.

Why is a database migration the thing that actually breaks rollback?

Because rollback only rewinds the application code — it has no idea the database schema moved on without it. If the current deploy ran a migration that dropped a column, renamed a table, or changed a type, and something about that deploy needs rolling back, the old container comes back up expecting a schema that no longer exists. The redeploy succeeds; the health check might even pass, if it isn't specifically checking the columns the old code needs; and the app then fails in whatever way querying a column that isn't there fails, which is usually worse than the original problem, because now it's happening on the version everyone trusted enough to call 'stable.'

This is also why running migrations automatically inside a container's startup is worth resisting on its own, separate from rollback. A migration gated only on a health check passing is one bad `ALTER TABLE` away from running mid-swap, against a database three containers might be touching at once during the overlap window this whole post is built around. Keeping migrations as a distinct, manual step — applied before the image that expects the new schema goes out, never bundled into the same automatic path as the deploy — means a migration failure is its own event with its own blast radius, not folded silently into whatever else is happening at deploy time.

  1. A migration that adds a column or table is safe to roll back through — the old code simply ignores the new structure it doesn't know about

  2. A migration that renames or drops a column is not safe — the old code expects something that's gone

  3. The fix is discipline, not tooling: expand first (add the new structure, deploy code that can use either), then contract later (remove the old structure only after the code depending on it is gone from every rollback target worth keeping)

  4. A rollback that only redeploys code, without checking what the last migration did, is a guess dressed up as a recovery procedure

Rollback undoes code instantly. It does not undo a schema migration — and pretending otherwise turns a five-minute incident into a two-hour one, right at the moment everyone's already counting on the rollback to be the fast part.

Where this fits in the rest of the pipeline

Health checks, traffic switching and rollback are one stage of a longer chain — the full git-push-to-production pipeline covers how an image gets to this point in the first place. The memory cost this post treats as a real constraint is the same failure mode covered in more depth in what happens when a VPS runs out of RAM, and the container mechanics underneath the health-check gate — what 'healthy' and 'running' actually mean to Docker — are unpacked further in the Docker concepts Coolify hides from you.

Further reading from XenGrowth

Where this work meets go-to-market

Working on zero-downtime deploys and instant rollbacks on a cheap VPS inside a commercial team? XenGrowth's work on go-to-market systems publishes operator guides on the revenue side of this work.

What makes a deploy actually zero-downtime?

Four questions on the gap between 'the new container started' and 'no user saw an error'. Most of the difficulty is in the parts that aren't the container.

1 / 4
Why isn't 'the container is running' good enough to switch traffic?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

zero-downtime deploymentrollbackCoolifyDockerself-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 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

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

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

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

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

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

The Security Mistakes I See New Self-Hosters Make

These aren't rare. They're the same seven patterns, documented in breach reports, CVE databases, and botnet postmortems, showing up on new self-hosted boxes on a loop — because the defaults that make setup fast are the same defaults that make a box exploitable.

Navigate

Don't Self-Host Until You Understand These 7 Things

This isn't a gate to keep you out. It's a readiness check — seven things worth being honest with yourself about before you're the one holding the pager, because a managed platform is still the right call for a lot of people right now.

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