Operations

Zero-downtime deploy with nginx and systemd on Ubuntu 24.04

Why zero-downtime deployments matter for production services

When you run a Linux VPS that serves live traffic, every restart of your application backend drops active connections. Users see HTTP 502 errors, incomplete requests, or timeouts. For a service that handles tens or hundreds of concurrent connections, this is unacceptable. The solution is a deployment strategy that keeps the old version running until the new version is ready to accept traffic, then switches over without a gap.

This tutorial walks you through a practical setup using nginx as a reverse proxy and systemd to manage two identical application sockets. The principle is simple: you run two instances of your app on different Unix sockets, nginx knows both, and you drain one before restarting it. No connection gets lost, no request times out.

Prerequisites

  • A VPS running Ubuntu 24.04 with a non-root sudo user
  • nginx installed (sudo apt install nginx)
  • A sample web application (this guide uses a simple Python Flask app for illustration; your real app follows the same pattern)
  • Basic familiarity with systemd unit files and nginx configuration

Step 1 - Create two identical application sockets using systemd socket units

Instead of running your application on a fixed port, run it on a Unix socket. systemd socket activation allows nginx to accept connections for the application through the socket. When you restart one instance, systemd closes the old socket and opens a new one, and nginx transparently sends new connections to the other socket.

First, create two socket unit files. Each will listen on a different path.

sudo mkdir -p /run/myapp
sudo nano /etc/systemd/system/myapp-v1.socket
[Unit]
Description=MyApp version 1 socket

[Socket]
ListenStream=/run/myapp/v1.sock
SocketMode=0660
SocketUser=www-data
SocketGroup=www-data

[Install]
WantedBy=sockets.target

Now create the second socket:

sudo nano /etc/systemd/system/myapp-v2.socket
[Unit]
Description=MyApp version 2 socket

[Socket]
ListenStream=/run/myapp/v2.sock
SocketMode=0660
SocketUser=www-data
SocketGroup=www-data

[Install]
WantedBy=sockets.target

Reload systemd and enable both sockets:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp-v1.socket myapp-v2.socket

Verify the sockets are listening:

sudo ss -xln | grep myapp

You should see two lines showing myapp-v1.sock and myapp-v2.sock.

Step 2 - Create the application service units

Each socket needs a corresponding service unit that launches the application. The service receives the file descriptor from systemd and binds directly to the socket.

sudo nano /etc/systemd/system/[email protected]
[Unit]
Description=MyApp instance on socket %i
BindsTo=myapp-%i.socket
After=myapp-%i.socket

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/myapp/app.py
Environment=SOCKET_PATH=/run/myapp/%i.sock
User=www-data
Group=www-data
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Note the %i placeholder, systemd substitutes the instance name (v1 or v2) at runtime. The flask application reads SOCKET_PATH and binds to that socket.

Enable the service instances for both sockets:

sudo systemctl enable [email protected] [email protected]
sudo systemctl start [email protected] [email protected]

Step 3 - Configure nginx as a reverse proxy with two upstreams

Create an nginx configuration that sends traffic to both application sockets using a upstream block.

sudo nano /etc/nginx/sites-available/myapp
upstream myapp_backend {
    server unix:/run/myapp/v1.sock;
    server unix:/run/myapp/v2.sock;
}

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://myapp_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Enable the site and test the configuration:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t

If the test passes, reload nginx:

sudo systemctl reload nginx

Step 4 - Perform a zero-downtime deployment

To deploy a new version, you will drain one socket, stop its service, update the application code, restart the service, and then switch traffic back.

First, tell nginx to drain connections from the v1 socket by marking it down. Use the nginx reload with a modified upstream config:

sudo sed -i 's/server unix:\/run\/myapp\/v1.sock;/server unix:\/run\/myapp\/v1.sock down;/' /etc/nginx/sites-available/myapp
sudo nginx -t && sudo systemctl reload nginx

Now stop the v1 service. systemd closes the socket gracefully, and any existing connections drain within the keepalive timeout.

sudo systemctl stop [email protected]

Deploy your new application code to /opt/myapp/. Then restart the v1 service:

sudo systemctl start [email protected]

Finally, restore the v1 socket in the nginx upstream by removing the down flag:

sudo sed -i 's/server unix:\/run\/myapp\/v1.sock down;/server unix:\/run\/myapp\/v1.sock;/' /etc/nginx/sites-available/myapp
sudo nginx -t && sudo systemctl reload nginx

Your application is now fully serving traffic from both sockets again, with the new code active on v1.

Repeat the same process for v2 when you need to deploy the next update. The key discipline: always drain and stop only one socket at a time.

Step 5 - Verify zero-downtime behavior with a live test

Open two terminals. In the first terminal, start a continuous curl to your application:

while true; do curl -o /dev/null -s -w "%{http_code}\n" http://yourdomain.com/; sleep 0.5; done

In the second terminal, run the deployment steps from Step 4. While you stop and restart the v1 service, you should see no 502 or 503 errors, only 200 responses. The second terminal's commands complete without any interruption visible to the curl loop.

This confirms that nginx transparently routes requests to the remaining healthy socket during the swap.

Comparison of deployment strategies

StrategyDowntime windowComplexityRollback ease
Simple restartSeveral seconds per restartLowEasy (restore old version)
Blue-green (this approach)Zero measurable downtimeMediumEasy (swap back)
Canary with nginx upstream weightsZero (with traffic splitting)HighComplex (needs progressive weight changes)

Troubleshooting common issues

Problem: nginx fails to start after reload
Run sudo nginx -t to find syntax errors. Common mistake: missing semicolon or incorrect path in the upstream block. Fix the syntax and reload again.

Problem: Application fails to bind to socket
Check systemd logs with sudo journalctl -u [email protected]. Ensure the /run/myapp directory exists and the www-data user has write permission. The socket unit file's SocketMode must grant access to www-data.

Problem: Clients see 502 Bad Gateway after deployment
This indicates nginx cannot reach the application socket. Verify the application is running: sudo systemctl status [email protected]. Check that the socket file exists: ls -la /run/myapp/. If the socket path in the nginx upstream does not match the application's SOCKET_PATH, fix the mismatch.

FAQ

Can I use this method with a Go or Node.js application?

Yes, the pattern works with any application that can bind to a Unix socket. For Go, use net.Listen("unix", path). For Node.js, use server.listen(path). The systemd socket unit and nginx upstream configuration remain identical.

Do I need two separate server instances for blue-green?

Not necessarily. The two sockets can point to the same application code. The strategy works because you stop and restart only one socket at a time, ensuring the other socket continues serving requests. You can run both on the same machine with the same codebase.

How do I handle stateful applications like HTTP sessions?

Store session state externally, in Redis, memcached, or a database, so that any instance can handle any request from any socket. Avoid storing session state in memory tied to a single process. If external storage is not possible, use nginx's ip_hash or sticky sessions with the upstream directive, but be aware that this may cause uneven load during drain.

Can I automate this deployment process with a script?

Absolutely. Write a bash script that runs the drain, stop, update, start, and restore commands in sequence. You can integrate this into CI/CD pipelines (GitLab CI, GitHub Actions) using SSH keys to run the script on the VPS.

Is this method compatible with Docker containers?

Yes, but with a twist. Instead of systemd socket activation, you would use Docker's port mapping with two containers, each bound to a different host port (e.g., 8081 and 8082). The nginx upstream then points to localhost:8081 and localhost:8082. The drain/restart logic remains the same.

What if I only have one socket available and my VPS has minimal resources?

For a low-traffic service, you can still achieve near-zero downtime by using nginx's proxy_next_upstream error timeout invalid_header http_502 directive with a single upstream server and a short keepalive timeout. However, the two-socket approach is more reliable and recommended for production services. If your VPS has very limited RAM (e.g., 1GB), consider using a configure swap and optimize memory on a low RAM VPS to handle two application instances.

Related articles

Note: This guide is for general reference. Every system and infrastructure has its own specifics, so test each step in a safe environment and consult a qualified engineer before applying it in production.