Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesHow do people do zero-downtime deploys for a small web app without a full SRE setup?
Most small teams want zero-downtime deploys for the same reason big SRE orgs do: users don’t care that you’re “pushing to prod,” they just see a broken app. The difference is you don’t have six platform engineers and a custom deployment system—you have a repo, some CI, and a tolerance for a little risk as long as it’s predictable.
This guide walks through how people actually get zero-downtime deploys for small web apps, starting from “we run one container” up to “we have controlled rollouts,” using primitives that behave like sharp tools instead of a homegrown control plane. I’ll anchor examples on Fly.io’s model (Machines, Fly Proxy, regions) because it’s built for exactly this “full production, no SRE team” situation, but the patterns apply more broadly.
Quick Answer: You get zero-downtime deploys by running at least two copies of your app behind a load balancer, updating them one at a time, and making connections drain gracefully—ideally with health checks and a rollout strategy baked into your platform.
The Quick Overview
- What It Is: A deployment pattern where new versions of your app roll out without dropping active connections, returning 500s, or causing visible blips for users.
- Who It Is For: Small teams and solo devs running production apps who want reliable deploys without building their own Kubernetes-lite.
- Core Problem Solved: How to ship frequently and safely when your app is stateful, traffic is 24/7, and you don’t have the time or headcount to babysit rollouts.
How It Works
Zero-downtime deploys are less about magic and more about choreography. The load balancer (or proxy) keeps serving requests while you rotate underlying app instances in and out, with strict rules about when an instance is considered “ready” and when it’s safe to kill it.
On Fly.io, that choreography looks like this:
- Run multiple Machines per app: Instead of one lonely process, you run 2+ Machines behind Fly Proxy. This gives you room to take one down while another still serves traffic.
- Use health checks and safe rollout settings: You configure health checks and deployment strategies so Fly only sends traffic to healthy Machines and replaces them gradually.
- Let the platform drain connections: During deploys, old Machines are marked as “to be replaced,” taken out of rotation, and shut down only after new ones are live.
The nice part: you don’t need to script this from scratch—fly deploy already knows how to do zero-downtime if you give it a bit of config and more than one Machine to work with.
How It Works, Step by Step (Practical Fly.io Version)
1. Start with at Least Two Machines
Zero-downtime with a single process is mostly wishful thinking. You need redundancy.
In fly.toml, make sure your app runs with a minimum count greater than one:
[app]
primary_region = "iad"
[vm]
memory = "512mb"
cpus = 1
[deploy]
strategy = "rolling"
[services]
internal_port = 8080
processes = ["app"]
[[services.ports]]
handlers = ["http"]
port = 80
[[services.tcp_checks]]
interval = "10s"
timeout = "2s"
Then scale Machines:
fly scale count 2
Now Fly Proxy has at least two targets. Taking one down for a deploy won’t strand users.
2. Add Health Checks So the Proxy Knows Who’s Alive
Health checks are how Fly Proxy decides whether to send traffic to a Machine.
For HTTP:
[[services.http_checks]]
path = "/health"
interval = "10s"
timeout = "2s"
method = "GET"
tls_skip_verify = false
grace_period = "30s"
Implement /health to be fast and boring. No DB migrations, no “am I fully warmed?” introspection. Just “can I handle a request?”
What this gets you:
- New Machines only get traffic after
/healthpasses. - Old Machines that fail checks are removed from rotation before they can hurt users.
3. Use a Rolling Deployment Strategy
This is the core of “zero downtime”: update a subset of Machines at a time, not all at once.
In fly.toml:
[deploy]
strategy = "rolling"
max_unavailable = 1
Behavior:
rolling: deploys replace Machines incrementally, not in a big bang.max_unavailable = 1: at most one Machine is allowed to be “out” during the rollout in each region.
With 2 Machines and max_unavailable = 1, Fly will:
- Start a new Machine on the new version.
- Wait for it to pass health checks.
- Take one old Machine out of service and stop it.
- Repeat until all are updated.
If a new Machine never goes healthy, the old one keeps serving. You get a failed deploy, but not a broken app.
4. Make Your App Shut Down Gracefully
The platform can drain traffic, but your app still needs to behave when it’s being shut down.
Typical best practice:
- Handle
SIGTERMand finish in-flight requests. - Stop accepting new connections quickly.
- Exit cleanly within a reasonable timeout (don’t hang forever).
Example (Node.js / Express):
const http = require('http');
const app = require('./app'); // your Express app
const server = http.createServer(app);
server.listen(process.env.PORT || 8080);
process.on('SIGTERM', () => {
console.log('Received SIGTERM, shutting down gracefully...');
server.close(err => {
if (err) {
console.error('Error during shutdown', err);
process.exit(1);
}
process.exit(0);
});
// Hard kill after 10s in case something is stuck
setTimeout(() => {
console.error('Forcefully shutting down');
process.exit(1);
}, 10000).unref();
});
Fly Machines will send a termination signal when they’re being stopped for a deploy. If your app drains in-flight work, users won’t notice.
5. Tie Deploys to CI/CD, Not Your Laptop
Manual deploys from a laptop work until someone’s on a plane and prod is burning.
Use CI (GitHub Actions, etc.):
name: Deploy to Fly
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -L https://fly.io/install.sh | sh
- run: ~/.fly/bin/flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
This keeps deploys:
- Repeatable (same command, same settings).
- Auditable (you know what SHA is running).
- Independent of whoever happened to have
flyctlinstalled locally.
Features & Benefits Breakdown
Here’s what “zero-downtime deploys without a full SRE team” actually buys you when you lean on something like Fly.io instead of building your own control plane.
| Core Feature | What It Does | Primary Benefit |
|---|---|---|
Rolling deploys via fly deploy | Gradually replaces Machines with new versions using health checks. | New releases go live without dropping traffic. |
| Health-checked Fly Proxy | Routes requests only to healthy Machines based on HTTP/TCP checks. | Bad versions or sick instances don’t break users. |
| Multi-region placement | Runs your app close to users across regions (iad, lhr, syd, etc.). | Low latency globally without multi-cluster drama. |
| Scale-to-zero & auto-scaling | Starts Machines fast enough to serve HTTP; only pay when CPU/mem are active. | Production elasticity without always-on overhead. |
You don’t have to learn Kubernetes rolling updates or write a blue/green orchestrator. The platform already knows how to spin, probe, and drain Machines; you just declare how cautious you want it to be.
Ideal Use Cases
- Best for small teams with 24/7 traffic: Because you can get zero-downtime deploys just by running 2+ Machines and using
rollingstrategy, without building an SRE-friendly deployment system. - Best for apps evolving quickly (many deploys/day): Because you can ship small changes often, and failed deploys just stall or roll back—users keep hitting healthy Machines.
Limitations & Considerations
Zero-downtime isn’t completely free; there are tradeoffs to keep in your head.
- You still need multiple replicas: With a single Machine, any deploy is inherently downtime-prone. The workaround is simple—
fly scale count 2—but it’s a hard requirement. - Database & migrations can still cause outages: Stateless app deploys can be zero-downtime; schema changes can’t always. Use backwards-compatible migrations (add columns, deploy code that uses them, then remove old paths) and be cautious about locks. If you use Fly Postgres, treat migrations as a separate, deliberate step, not an afterthought in
release_command.
Other notes:
- If you use sticky sessions (websockets, in-memory auth state), think about how new Machines handle handoffs.
- Long-running requests (large file uploads, slow reports) might get interrupted if your shutdown timeout is too aggressive.
Pricing & Plans (Practical Perspective)
On Fly.io, you don’t buy a special “zero-downtime” SKU; you pay for the Machines you run and the seconds they’re active. That’s handy for small teams: you get full-blown rolling deploys using the same primitives you’d use for any other workload.
A simple way to think about it:
- Single-region, few-Machine setup: Best for small teams needing a straightforward “always on” app in one primary region. Run 2 Machines to get zero-downtime deploys and maybe a third for background jobs or cron.
- Multi-region, latency-sensitive setup: Best for teams serving users across continents who want both low latency and safe rollouts. You run 2–3 Machines per region; rolling deploys happen region by region so there’s always capacity somewhere.
You’re effectively paying for “Slack SRE-level deploy behavior” at the price of a couple of modest VMs, down to the second of CPU/memory usage.
Frequently Asked Questions
Do I really need more than one instance for zero-downtime deploys?
Short Answer: Yes. Without at least two instances, every deploy is a short outage with extra steps.
Details: Zero-downtime means “someone somewhere is always answering requests.” With a single process or Machine, when you restart it for a new version, there’s literally nothing available to handle requests. You can reduce the blast radius—fast restarts, diversion to error pages—but you can’t call it zero-downtime with a straight face. On Fly.io, running fly scale count 2 plus rolling deploys and health checks is the minimum viable setup.
How do I avoid downtime when running database migrations?
Short Answer: Make migrations backwards-compatible and decouple them from app deploys when they’re risky.
Details: Zero-downtime app deploys don’t magically fix schema changes. Patterns that people actually use:
- Additive-first: Add new tables/columns/indices in one migration, deploy code that uses them in a compatible way, then clean up old schema in a later migration.
- Non-blocking migration windows: For heavy migrations (big table rewrites), run them during off-peak hours and monitor closely. If you use Fly Postgres, treat those like mini-maintenance events.
- Separate migration step: Instead of running migrations as part of every
fly deploy, run them via a separate Machine or CI job once the new code is deployed but not yet dependent on the new schema. That way, a failed migration doesn’t bring your app down or block the rollout.
You’re trading a bit of complexity in schema design for keeping the app healthy throughout the change.
Summary
Zero-downtime deployments for a small web app don’t require a bespoke control plane or a full SRE crew. They require a few disciplined moves:
- Run at least two copies of your app behind a smart proxy.
- Use health checks so the platform knows when an instance is ready.
- Configure rolling deploys so new Machines come up before old ones go down.
- Handle shutdown signals gracefully so in-flight requests finish cleanly.
- Treat risky database migrations as their own operation, not a surprise side effect.
On Fly.io, that all collapses into: configure fly.toml, scale to 2+ Machines, use rolling deploy strategy, and let Fly Proxy plus Machines do the choreography.
Next Step
Get Started(https://sprites.dev/)