Executing Zero Downtime Container Updates on VPS

Executing Zero Downtime Container Updates on VPS

What You’ll Need

Table of Contents

Understanding Blue-Green Deployments on Single-Node VPS

Deploying updates to software applications running inside Docker containers often results in brief service interruptions. When you run docker compose down && docker compose up -d, Docker stops the running container, tears down its networking interfaces, pulls the new image, and launches the updated container. During those 5 to 30 seconds, incoming HTTP requests fail with connection refused errors. Even a simple docker restart drops current TCP connections and interrupts active users.

Zero downtime container updates eliminate this window of failure. On expensive enterprise infrastructure, platforms like Kubernetes solve this with rolling updates and node pools. However, you do not need a multi-node Kubernetes cluster to achieve zero downtime deployments. You can achieve 100 percent uptime during application updates on a single virtual private server by using a pattern called Blue-Green deployment combined with Nginx process reloading.

In a Blue-Green single-node architecture, two identical application container services exist on your host network. One environment, designated as Blue, actively serves live user traffic through your reverse proxy. The second environment, designated as Green, remains idle or runs the previous release version.

When you publish a new container image, your automation script performs the following tasks:

  1. Pulls the latest image tag to the VPS host.
  2. Identifies which environment is currently idle (for example, Green).
  3. Launches the new application container on the Green environment using an isolated internal port.
  4. Executes continuous health checks against the Green container until it returns a HTTP 200 status code.
  5. Updates the active upstream target inside the Nginx reverse proxy configuration.
  6. Signals Nginx to reload its configuration without dropping active client connections (nginx -s reload).
  7. Stops the older Blue application container once all active connections complete gracefully.

By leveraging Nginx’s native master-worker process architecture, the proxy reloads its configuration in memory within milliseconds. New TCP connections immediately route to the newly deployed container, while existing long-lived connections finish processing on the old container before it terminates.

Configuring the Reverse Proxy and Compose Stack

To implement this workflow on your server, you need a structured layout for your Docker Compose files and your reverse proxy settings. We will configure an Nginx proxy container that listens on port 80 and routes incoming traffic to either port 8081 (Blue container) or port 8082 (Green container).

First, set up your project directory on your host. If you are provisioning fresh hardware, spinning up an instance on Hetzner VPS or Contabo VPS provides the low-latency networking and high CPU performance required to compile and start secondary containers quickly without exhausting system resources.

When working with heavy dynamic applications or database containers, remember that disk IOPS can become a bottleneck during dual-container operation. Check out our guide on Optimizing Container Volume Performance On Hetzner VPS to ensure your disk read speeds do not drop during updates.

Create a directory named /opt/app on your VPS and populate it with the following files.

docker-compose.yml

version: '3.8'

networks:
  app-network:
    driver: bridge

services:
  nginx-proxy:
    image: nginx:1.25-alpine
    container_name: nginx-proxy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    networks:
      - app-network

  app-blue:
    image: myregistry.azurecr.io/myapp:latest
    container_name: app-blue
    restart: always
    environment:
      - PORT=8080
      - COLOR=blue
    ports:
      - "127.0.0.1:8081:8080"
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 10s

  app-green:
    image: myregistry.azurecr.io/myapp:latest
    container_name: app-green
    restart: always
    environment:
      - PORT=8080
      - COLOR=green
    ports:
      - "127.0.0.1:8082:8080"
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 10s

Next, create the primary Nginx configuration file inside /opt/app/nginx/nginx.conf. This main configuration delegates upstream routing to an included file inside the /opt/app/nginx/conf.d directory.

nginx/nginx.conf

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    include /etc/nginx/conf.d/*.conf;
}

Now, create the active upstream configuration in /opt/app/nginx/conf.d/active_service.conf. By default, we will point active requests to the app-blue service listening on port 8081.

nginx/conf.d/active_service.conf

upstream backend_nodes {
    server 127.0.0.1:8081 max_fails=3 fail_timeout=5s;
}

server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://backend_nodes;
        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_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }
}

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.

Writing the Zero Downtime Bash Deployment Script

Now that our reverse proxy and dual container configurations are prepared, we need an automated shell script that manages image pulling, container startup, health verification, and traffic switching.

Create a file at /opt/app/deploy.sh and make it executable with chmod +x /opt/app/deploy.sh. This script contains logic to read the live configuration, launch the target container, confirm its operational status via HTTP, update the Nginx configuration, and terminate the deprecated container safely.

deploy.sh

#!/usr/bin/env bash

set -euo pipefail

APP_DIR="/opt/app"
CONF_FILE="$APP_DIR/nginx/conf.d/active_service.conf"
DOCKER_COMPOSE_FILE="$APP_DIR/docker-compose.yml"

cd "$APP_DIR"

echo "=== Pulling latest Docker images ==="
docker compose pull app-blue app-green

IS_BLUE_ACTIVE=0
if grep -q "127.0.0.1:8081" "$CONF_FILE"; then
    IS_BLUE_ACTIVE=1
fi

if [ "$IS_BLUE_ACTIVE" -eq 1 ]; then
    CURRENT_COLOR="blue"
    TARGET_COLOR="green"
    TARGET_PORT="8082"
    TARGET_SERVICE="app-green"
    OLD_SERVICE="app-blue"
else
    CURRENT_COLOR="green"
    TARGET_COLOR="blue"
    TARGET_PORT="8081"
    TARGET_SERVICE="app-blue"
    OLD_SERVICE="app-green"
fi

echo "Current active target: $CURRENT_COLOR"
echo "Deploying updates to: $TARGET_COLOR (Port: $TARGET_PORT)"

echo "=== Recreating target container ($TARGET_SERVICE) ==="
docker compose up -d --force-recreate "$TARGET_SERVICE"

HEALTH_CHECK_URL="http://127.0.0.1:$TARGET_PORT/health"
MAX_RETRIES=30
SLEEP_INTERVAL=2
HEALTHY=0

echo "=== Verifying container health at $HEALTH_CHECK_URL ==="
for ((i=1; i<=MAX_RETRIES; i++)); do
    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_CHECK_URL" || true)
    if [ "$HTTP_STATUS" -eq 200 ]; then
        echo "Health check passed on attempt $i (HTTP status 200)"
        HEALTHY=1
        break
    fi
    echo "Attempt $i/$MAX_RETRIES failed with HTTP status '$HTTP_STATUS'. Retrying in ${SLEEP_INTERVAL}s..."
    sleep "$SLEEP_INTERVAL"
done

if [ "$HEALTHY" -ne 1 ]; then
    echo "ERROR: Target container $TARGET_SERVICE failed health checks. Aborting deployment."
    echo "Stopping unhealthy target container..."
    docker compose stop "$TARGET_SERVICE"
    exit 1
fi

echo "=== Updating Nginx Upstream Configuration ==="
cat <<EOF > "$CONF_FILE"
upstream backend_nodes {
    server 127.0.0.1:$TARGET_PORT max_fails=3 fail_timeout=5s;
}

server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://backend_nodes;
        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_set_header X-Forwarded-Proto \$scheme;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }
}
EOF

echo "=== Reloading Nginx Proxy Process ==="
docker compose exec -T nginx-proxy nginx -s reload

echo "=== Waiting 5 seconds to drain active connections on old container ==="
sleep 5

echo "=== Stopping old container ($OLD_SERVICE) ==="
docker compose stop "$OLD_SERVICE"

echo "=== Deployment Complete! Active node is now: $TARGET_COLOR ==="

This deployment script guarantees safety. If the newly pulled image contains a runtime error that prevents the service from initializing, the curl verification loop times out, the deployment aborts, the failing container is stopped, and the active live container continues processing client requests without experiencing a single millisecond of downtime.

Automating Deployments via Webhooks and Volume Considerations

Manual invocation of deployment scripts works well, but high-velocity automated deployment workflows rely on trigger events emitted by continuous integration pipelines like GitHub Actions or GitLab CI.

To execute this deployment script automatically from your GitHub or GitLab CI actions without exposing raw root SSH access, you can construct secure HTTP listeners. Read our detailed guide on Building Secure Webhook Endpoints With HMAC Verification to ensure unauthorized actors cannot trigger container switches.

If your containers run background data fetching tasks, such as those covered in Building Web Scraping Data Pipelines on VPS, make sure your jobs handle graceful shutdown signals (SIGTERM) so in-flight requests finish cleanly before the old container dies.

Handling Stateful Data and Database Migrations

When running zero-downtime updates, your application architecture must accommodate database schemas and file storage differences between the old and new release versions.

  1. Database Schema Modifications: Always execute non-breaking database migrations before switching traffic to the new container. For example, if you need to rename a table column, perform a three-step migration process across releases:

    • Release A: Add the new column alongside the old column and write data to both fields.
    • Release B: Deploy the new code using the Blue-Green script shown above.
    • Release C: Drop the deprecated column from the database after verifying the new code functions properly.
  2. Shared Mount Volumes: Never let both Blue and Green containers write to isolated, un-synchronized internal file directories. Mount shared external storage paths using persistent volume drivers on host directories such as /var/app/data to ensure both container versions interact with identical local files.

  3. Graceful Termination Signals: Ensure your application handles standard Linux shutdown signals properly. When docker compose stop app-blue is invoked, Docker sends a SIGTERM signal to process ID 1 inside the container. Your web application code must capture SIGTERM, stop accepting new socket connections, finish processing currently open HTTP requests, flush open database transactions, and terminate cleanly.

Here is an example NodeJS initialization snippet that handles graceful termination correctly:

const express = require('express');
const app = express();
const port = process.env.PORT || 8080;

app.get('/health', (req, res) => {
    res.status(200).send('OK');
});

app.get('/', (req, res) => {
    res.send(`Running on instance color: ${process.env.COLOR || 'unknown'}`);
});

const server = app.listen(port, () => {
    console.log(`Application started on port ${port}`);
});

process.on('SIGTERM', () => {
    console.log('SIGTERM signal received. Closing HTTP server connections...');
    server.close(() => {
        console.log('All pending HTTP requests resolved. Exiting process.');
        process.exit(0);
    });
});

By pairing application-level graceful shutdown handling with Nginx in-memory configuration reloads, your single VPS setup delivers identical deployment reliability to complex distributed application clusters at a fraction of the cost.

Getting Started

To implement zero downtime deployments on your host infrastructure, choose a reliable server provider, setup Docker Compose, and configure your reverse proxy script following this guide:

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online