23.10. Zero-Downtime Deployment
Table of Contents
Why Zero-Downtime Deployment Matters
When you deploy a new version of your backend, you usually restart processes, migrate databases, and update configuration. If you do this in a naive way, the application becomes unavailable for a short period. Users see errors, timeouts, or failed requests.
Zero-downtime deployment is about deploying new versions without interrupting existing traffic. Requests continue to succeed while the new version is rolled out, and users should not notice any disruption.
You care about this when:
- You have real users in different time zones, so there is no true "maintenance window".
- Your API is used by other services that expect high availability.
- You are doing continuous delivery and deploy often.
Typical problems you want to avoid:
- Requests failing during restart.
- Half-deployed state, where some instances run the old version and some the new version, but they are incompatible.
- Long-running requests being killed in the middle of processing.
In this chapter, you will see common patterns and practical techniques to keep your backend online during deployments.
Core Strategies for Zero-Downtime
There are many tools and platforms, but most zero-downtime strategies are built from a small set of ideas.
Blue-Green Deployments
In a blue-green deployment, you maintain two production environments:
- Blue: the currently active version that serves all traffic.
- Green: the new version, deployed and tested in parallel.
When the green version is ready:
- Route traffic from blue to green, usually by updating:
- A load balancer configuration.
- A reverse proxy configuration.
- A DNS record, in some setups.
- Keep the blue environment running for a while:
- If something goes wrong, you switch traffic back to blue quickly.
A simplified flow:
- Current production:
v1on environment blue. - Deploy
v2to environment green, but do not send user traffic yet. - Run smoke tests against green (health checks, basic API tests).
- Switch traffic to green.
- Monitor errors and metrics.
- If stable, decommission blue or prepare it to host the next deployment.
Advantages:
- Very fast rollback: just route traffic back to the previous environment.
- You can run real tests against the new version before it serves all users.
- Clear separation between old and new versions.
Disadvantages:
- You need enough resources for two copies of your environment.
- Database changes can still be a problem if not handled carefully.
Rolling Deployments
With a rolling deployment, you update your application instances gradually. At any moment, some instances run the old version and some run the new version.
For example, imagine you have 4 application containers behind a load balancer:
- Mark container 1 as unavailable in the load balancer.
- Stop container 1, start new version, wait for it to be healthy.
- Add container 1 back to the load balancer.
- Repeat for containers 2, 3, and 4.
During the rollout:
- The load balancer always has at least 3 healthy instances to handle traffic.
- Requests continue to succeed.
Advantages:
- Does not require a full duplicate environment.
- Works well with container orchestrators like Kubernetes, which can manage rolling updates natively.
Disadvantages:
- For some time, both versions coexist, which increases the chance of compatibility issues between old and new code.
- Rollback is slower since you must roll back each instance.
Canary Releases
A canary release sends only a small amount of traffic to the new version at first.
Example:
- 95% of traffic goes to version
v1. - 5% goes to new version
v2(the canary). - You compare metrics:
- Error rate.
- Latency.
- Business metrics like signup success.
- If metrics look good, gradually increase
v2traffic: - 10%, 25%, 50%, 100%.
- If something is wrong, send all traffic back to
v1.
This is similar to blue-green, but the switch is gradual, not all at once.
Canary releases are often implemented with:
- Load balancers that support weights.
- Service meshes that can route based on percentages.
- API gateways that split traffic by HTTP headers or user groups.
In-Place Restart with Graceful Shutdown
Sometimes you do not have advanced infrastructure, but you can still reduce downtime by:
- Running multiple processes or containers.
- Restarting them carefully, one by one.
- Using graceful shutdown logic so that in-flight requests can finish.
You can combine:
- A reverse proxy (like Nginx) that routes to multiple backend instances.
- A process manager (like systemd or Supervisor) that restarts instances.
- Your application’s own graceful shutdown behavior.
This approach is often the simplest path to "almost zero" downtime for small setups.
Load Balancers and Traffic Switching
Load balancers are central to zero-downtime deployments. They control where traffic goes, which lets you add or remove backend instances without breaking clients.
Using a Reverse Proxy as a Load Balancer
For many small to medium systems, you can use Nginx as:
- Reverse proxy at the front.
- Load balancer across several backend instances.
A basic Nginx upstream with two FastAPI/Uvicorn instances:
upstream myapp {
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 80;
location / {
proxy_pass http://myapp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}To update with near zero downtime:
- Start new instances on different ports.
- Add them to the
upstreamblock. - Reload Nginx with
nginx -s reload(reload is graceful). - Remove old instances when they are no longer used.
You can implement a simple rolling update by:
- Adding new instances.
- Waiting for them to be healthy.
- Removing old instances.
Health Checks
Health checks tell the load balancer which instances are healthy.
Typical patterns:
- A
/healthor/liveendpoint that returns200 OKwhen: - Database is reachable.
- Dependencies are healthy enough to serve.
- Nginx or other load balancers periodically call this endpoint.
- Unhealthy instances are removed automatically.
Example of a simple FastAPI health check:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}Health checks are critical during deployments:
- Newly started instances must report healthy before receiving traffic.
- Instances that are shutting down can mark themselves as unhealthy (or be removed from the load balancer) before they stop.
Traffic Weighting and Routing
More advanced systems let you control traffic by weights. For example:
- Version
v1: weight 9. - Version
v2: weight 1.
This means that ~90% of requests go to v1 and ~10% to v2.
You can also route by:
- User segment (e.g., internal users first).
- HTTP header (e.g.,
X-Canary: true). - Geographic region.
This routing flexibility is used in canary releases and A/B testing.
Graceful Shutdown and Draining Connections
Your backend must support graceful shutdown, or you risk killing in-flight requests and causing errors during deployment.
What Is Graceful Shutdown?
During a graceful shutdown:
- Your process stops accepting new connections.
- It waits for active requests to complete.
- It releases resources, such as:
- Database connections.
- Message queue connections.
- File handles.
- It then exits cleanly.
Without graceful shutdown, deployment tools or system managers may send a kill signal that stops the process immediately and drops active requests.
Signals and Shutdown in Python Servers
Many servers respond to POSIX signals:
SIGTERM: polite request to terminate.SIGINT: interrupt, often from Ctrl+C.SIGKILL: forceful kill, no chance to clean up.
Uvicorn, which you commonly use with FastAPI, already supports graceful shutdown by default. When it receives SIGTERM or SIGINT, it:
- Stops accepting new connections.
- Waits for ongoing requests to finish up to a timeout.
- Shuts down.
You can customize startup and shutdown hooks in FastAPI:
from fastapi import FastAPI
app = FastAPI()
@app.on_event("startup")
async def on_startup():
# Initialize database connections, caches, etc.
...
@app.on_event("shutdown")
async def on_shutdown():
# Close database connections, flush logs, etc.
...These hooks are run for each process when it starts and stops, which is very helpful for graceful deployments.
Connection Draining
Connection draining is the process of gradually removing an instance from service:
- Mark instance as "draining" in the load balancer:
- No new connections are sent to it.
- Existing connections are allowed to continue.
- Wait for active requests to complete, up to some timeout.
- Stop the application instance.
You can implement this with:
- Load balancer configuration that supports "draining" or "deregistering" targets.
- In a simpler Nginx setup, by:
- Removing the instance from the
upstreamconfig. - Reloading Nginx.
- Waiting some seconds, then stopping the instance.
If you forget draining, you may kill in-flight requests and cause user-visible errors.
Always combine deployments with graceful shutdown and connection draining.
Killing processes abruptly during deployment can lead to failed API calls, partial writes, and user-facing errors.
Database Migrations Without Downtime
Even if your application processes are deployed perfectly, database changes can break zero-downtime if not planned.
Problem cases:
- Removing a column that old code still uses.
- Renaming a column.
- Changing a column type incompatibly.
- Adding a constraint that old data violates.
To achieve zero-downtime, your schema migrations must be backward compatible with both old and new versions of the code, at least during the transition.
Backward Compatible Changes
A change is backward compatible if the old version of your app still works correctly after the change. Common safe changes:
- Adding a new nullable column.
- Adding a new table.
- Adding a new index.
- Adding a new column with a default value that does not break old code.
Unsafe changes that require extra care:
- Dropping columns or tables.
- Renaming columns or tables.
- Tightening constraints in a way that may break existing data.
Expand and Contract Pattern
A very common approach is often called "expand and contract".
- Expand phase: make schema changes that support both old and new code.
- Deploy new code that uses the new schema.
- Contract phase: remove old parts of the schema that are no longer used.
Example: renaming a column full_name to name.
Naive approach (bad for zero-downtime):
- Run migration: rename
full_nametoname. - Deploy code that expects
name.
If an instance still runs old code, it will break because full_name no longer exists.
Expand and contract approach:
- Expand: add a new column
nameand keepfull_name:
ALTER TABLE users
ADD COLUMN name TEXT;- Deploy code version
v2that: - Writes to both
full_nameandname. - Reads from
nameif available, otherwise falls back tofull_name. - Run a background job or one-time script that copies:
UPDATE users
SET name = full_name
WHERE name IS NULL AND full_name IS NOT NULL;- Once you are sure all running code handles
name, deployv3that: - Reads only
name. - Writes only
name. - Contract: drop the old column:
ALTER TABLE users
DROP COLUMN full_name;At each step, both old and new versions of the code, and the database, can coexist without breaking.
Rule for zero-downtime database changes:
Each migration step must be safe to run while both the old and new versions of the application are live at the same time.
Long-Running Migrations
Some migrations can take a long time, for example:
- Adding an index on a huge table.
- Altering a column type.
- Deleting a lot of rows.
Long migrations can:
- Lock tables for a long time.
- Slow down the database for both old and new application instances.
To reduce risk:
- Run heavy operations in smaller batches.
- Use tools or DB features for concurrent index creation.
- Run migrations during lower-traffic periods, even if you do not stop traffic entirely.
- Monitor database load and cancel or pause if necessary.
For PostgreSQL, prefer operations like:
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
instead of blocking CREATE INDEX, when possible.
Docker and Container-Based Zero-Downtime
Containers make it easier to run multiple versions of your app at the same time, which is ideal for zero-downtime deployment.
Basic Pattern with Docker and Nginx
Imagine a simple setup:
- Nginx reverse proxy on the host.
- Several Docker containers running your FastAPI app.
Steps for a rolling update:
- Start containers with the new image:
docker run -d --name app_v2_1 -p 8003:8000 myapp:2.0
docker run -d --name app_v2_2 -p 8004:8000 myapp:2.0- Add them to Nginx
upstream:
upstream myapp {
server 127.0.0.1:8001; # old v1
server 127.0.0.1:8002; # old v1
server 127.0.0.1:8003; # new v2
server 127.0.0.1:8004; # new v2
}- Reload Nginx:
nginx -s reload- Monitor logs, metrics, and errors.
- When new version is stable, remove old instances:
- Update
upstreamto only include ports 8003 and 8004. - Reload Nginx.
- Stop old containers:
docker stop app_v1_1 app_v1_2
docker rm app_v1_1 app_v1_2During this sequence, there is always at least one healthy instance of your app ready to serve traffic.
Docker Compose and Zero-Downtime
Docker Compose alone does not guarantee zero-downtime, but you can:
- Scale up new versions.
- Switch the reverse proxy to new containers.
- Scale down old versions.
You can define multiple services for different versions, but in production you often move to more capable orchestrators, or manage Nginx manually.
Kubernetes and Rolling Updates
Kubernetes has built-in support for rolling updates and zero-downtime features:
- Deployments: specify the desired number of replicas and update strategy.
- Readiness probes: control when a pod starts receiving traffic.
- Liveness probes: detect and restart failed pods.
- RollingUpdate strategy: gradually replace pods with new version.
A simplified flow in Kubernetes:
- You update the image in the Deployment spec.
- Kubernetes:
- Creates a new pod with the new image.
- Checks readiness probe.
- When ready, sends traffic, then stops an old pod.
- It repeats until all old pods are replaced.
Kubernetes is beyond the scope of this chapter, but it is important to know that orchestration platforms can automate much of the zero-downtime process.
Handling Stateful Components
Zero-downtime is simpler when your application is stateless:
- No session data stored in memory.
- No user-specific state tied to a particular instance.
However, some components are stateful and need extra attention.
Sticky Sessions
Sticky sessions mean that a user is always routed to the same backend instance, usually to keep state in memory.
This is problematic for zero-downtime because:
- If you kill the instance, the user loses their in-memory state.
- You cannot easily drain connections and move users to other instances.
Better approaches:
- Store session data in a shared datastore, such as Redis.
- Avoid in-memory-only sessions in production systems that require high availability.
Background Jobs
Background workers and job queues are another stateful area:
- You might have Celery workers consuming from Redis or a message broker.
- They can be deploying a new version of the worker code too.
Zero-downtime worker deployment:
- Stop sending new jobs to old workers:
- For example, scale down old workers gradually.
- Let old workers finish their current tasks.
- Start new workers with the new code.
- Ensure job schemas and payload formats are backward compatible during overlap.
A common pattern: when you change the structure of job messages, follow an expand and contract pattern similar to database migrations.
Caches
Cached data can be:
- Incorrect if you change code that interprets cached entries differently.
- Using keys that differ between versions.
To avoid cache-related downtime issues:
- Use cache namespaces or versioned keys when you change cache formats.
- Plan for cache invalidation:
- Clear or expire affected keys when deploying new code that reads them.
Step-by-Step Example: Rolling Update with FastAPI and Nginx
To make the ideas more concrete, here is a simplified step-by-step example that keeps downtime practically zero.
Assume:
- Nginx reverse proxy on the host.
- FastAPI app served by Uvicorn in Docker containers.
- Two existing containers with version
v1.
1. Current State
Nginx configuration:
upstream myapp {
server 127.0.0.1:8001; # app_v1_1
server 127.0.0.1:8002; # app_v1_2
}
server {
listen 80;
location / {
proxy_pass http://myapp;
}
}Running containers:
docker run -d --name app_v1_1 -p 8001:8000 myapp:1.0
docker run -d --name app_v1_2 -p 8002:8000 myapp:1.02. Build and Start New Version
Build new Docker image:
docker build -t myapp:2.0 .Start two new containers:
docker run -d --name app_v2_1 -p 8003:8000 myapp:2.0
docker run -d --name app_v2_2 -p 8004:8000 myapp:2.03. Update Load Balancer
Update upstream in Nginx:
upstream myapp {
server 127.0.0.1:8001; # old v1
server 127.0.0.1:8002; # old v1
server 127.0.0.1:8003; # new v2
server 127.0.0.1:8004; # new v2
}Reload Nginx gracefully:
nginx -s reloadAt this point:
- Requests are distributed across both v1 and v2.
- If you want a canary, you can temporarily make v2 less likely to receive traffic by adjusting weights or number of instances.
4. Monitor and Verify
Monitor:
- Application logs.
- Error rates.
- Latency.
- Any custom health dashboard.
If you detect serious problems, remove v2 instances from the config, reload Nginx, and stop v2 containers. All traffic returns to v1.
5. Remove Old Instances
When you are comfortable with v2:
- Update
upstreamto use only new instances:
upstream myapp {
server 127.0.0.1:8003; # new v2
server 127.0.0.1:8004; # new v2
}- Reload Nginx:
nginx -s reload- Stop and remove old containers:
docker stop app_v1_1 app_v1_2
docker rm app_v1_1 app_v1_2During this entire process, Nginx always had at least one healthy backend, so users did not experience noticeable downtime.
Common Pitfalls and How to Avoid Them
Even with good plans, zero-downtime deployments can fail because of a few recurring mistakes.
Incompatible Changes Between Old and New Versions
Problem scenarios:
- Old code sends requests or messages that new code cannot handle.
- New code expects a database column that the migration has not yet created on all nodes or replicas.
- New code expects a new field in JSON that old clients do not send yet.
Avoid this by:
- Designing changes to be backward compatible.
- Using feature flags to turn new behavior on only after deployment.
- Using expand and contract patterns for database and message schema changes.
Ignoring Background Tasks and Scheduled Jobs
If you deploy only the web application but forget about workers:
- Old workers keep running old code and produce inconsistent data.
- Jobs may fail if they rely on new database schema or new code.
Plan worker deployments as part of your overall zero-downtime strategy.
Lack of Monitoring and Observability
You cannot declare success for a deployment if you do not observe what happens.
At minimum, track:
- Error rate.
- Latency (p95, p99).
- Resource usage (CPU, memory).
- Health checks.
More advanced metrics:
- Request volume by endpoint.
- Business success metrics (e.g., login success, purchase completion).
Zero-downtime is not only "the server stays up". It also means "users do not see new errors or degraded performance."
Not Testing the Deployment Process
You should:
- Test your deployment scripts or pipeline in a staging environment.
- Simulate production as closely as possible:
- Same database schema.
- Same or similar traffic if you can replay it.
You do not want the first time you run a new deployment approach to be in production.
Putting It All Together
Zero-downtime deployment combines several practices:
- Multiple instances of your app, behind a load balancer.
- Graceful shutdown and connection draining so that restarts do not kill requests.
- Blue-green, rolling, or canary strategies to introduce new versions gradually.
- Backward compatible database migrations using expand and contract.
- Careful handling of stateful components such as sessions, workers, and caches.
- Monitoring and fast rollback capability if something goes wrong.
As your system grows, you might adopt sophisticated tools like Kubernetes, service meshes, and advanced CI/CD pipelines. However, the core ideas remain the same. Even with a basic setup of Nginx, Docker, and FastAPI, you can achieve near zero downtime by following the patterns described in this chapter.
Views: 8
KAHIBARO