`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.
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
A migration that renames or drops a column is not safe — the old code expects something that's gone
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)
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
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
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.
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.













