DEV Community

Domonique Luchin
Domonique Luchin

Posted on

Zero-downtime deployments on a single Vultr VPS with Docker and Nginx

I run six AI-powered businesses from a single Vultr VPS. When I deploy updates to my VAPI agents or Supabase integrations, downtime costs me real money. Customers calling my AI phone systems expect 24/7 availability.

Here's how I achieve zero-downtime deployments using Docker and Nginx on one $40/month server.

The Problem with Basic Deployments

Most developers stop their container, pull new code, rebuild, and restart. This creates 30-60 seconds of downtime per deployment. For my Load Bearing Empire businesses, that's unacceptable.

Your users hit 502 errors. Your monitoring tools send alerts. You lose credibility.

My Zero-Downtime Setup

I use Docker Compose with multiple container instances behind Nginx. During deployments, I spin up new containers before stopping old ones.

Here's my production structure:

/opt/apps/
├── business-app/
│   ├── docker-compose.yml
│   ├── nginx.conf
│   └── deploy.sh
└── nginx/
    └── sites-enabled/
        └── business-app.conf
Enter fullscreen mode Exit fullscreen mode

Docker Compose Configuration

My docker-compose.yml runs two instances of each service:

version: '3.8'
services:
  app-blue:
    build: .
    container_name: business-app-blue
    ports:
      - "3001:3000"
    environment:
      - NODE_ENV=production
    restart: unless-stopped

  app-green:
    build: .
    container_name: business-app-green
    ports:
      - "3002:3000"
    environment:
      - NODE_ENV=production
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

I call this blue-green deployment. One container serves traffic while the other stays ready for updates.

Nginx Load Balancer Setup

Nginx distributes requests between containers. Here's my /etc/nginx/sites-enabled/business-app.conf:

upstream business_app {
    server localhost:3001 weight=100;
    server localhost:3002 weight=0 backup;
}

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://business_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_cache_bypass $http_upgrade;
    }
}
Enter fullscreen mode Exit fullscreen mode

The weight=0 setting keeps the green container as backup. During deployments, I flip these weights.

The Deployment Script

My deploy.sh script handles the entire zero-downtime process:

#!/bin/bash

ACTIVE_PORT=$(curl -s http://localhost/health | grep -o '"port":[0-9]*' | cut -d: -f2)

if [ "$ACTIVE_PORT" = "3001" ]; then
    DEPLOY_CONTAINER="app-green"
    DEPLOY_PORT="3002"
    ACTIVE_WEIGHT="weight=100"
    DEPLOY_WEIGHT="weight=100"
else
    DEPLOY_CONTAINER="app-blue"  
    DEPLOY_PORT="3001"
    ACTIVE_WEIGHT="weight=100"
    DEPLOY_WEIGHT="weight=100"
fi

echo "Deploying to $DEPLOY_CONTAINER on port $DEPLOY_PORT"

# Pull latest code and rebuild
git pull origin main
docker-compose build $DEPLOY_CONTAINER
docker-compose up -d $DEPLOY_CONTAINER

# Wait for container health check
sleep 10
curl -f http://localhost:$DEPLOY_PORT/health || exit 1

# Update Nginx upstream weights
sed -i "s/server localhost:$DEPLOY_PORT weight=[0-9]*/server localhost:$DEPLOY_PORT $DEPLOY_WEIGHT/" /etc/nginx/sites-enabled/business-app.conf

# Reload Nginx (zero downtime)
nginx -s reload

# Wait 30 seconds for connections to drain
sleep 30

# Set old container as backup
OLD_PORT=$((3003 - $DEPLOY_PORT))
sed -i "s/server localhost:$OLD_PORT weight=[0-9]*/server localhost:$OLD_PORT weight=0 backup/" /etc/nginx/sites-enabled/business-app.conf

nginx -s reload
echo "Deployment complete"
Enter fullscreen mode Exit fullscreen mode

Health Checks Are Critical

Your application needs a /health endpoint. Mine returns the container port and timestamp:

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    port: process.env.PORT || 3000,
    timestamp: new Date().toISOString()
  });
});
Enter fullscreen mode Exit fullscreen mode

The deployment script uses this endpoint to verify the new container works before switching traffic.

Database Migration Strategy

For database changes, I run migrations before deployment:

# Run migrations first
npm run migrate:up

# Then deploy application
./deploy.sh
Enter fullscreen mode Exit fullscreen mode

This works because I design backward-compatible migrations. New columns get default values. Old columns stay until the next deployment cycle.

Monitoring Your Deployments

I monitor deployment success with simple curl commands:

# Check both containers respond
curl -f http://localhost:3001/health
curl -f http://localhost:3002/health

# Monitor response times
curl -w "@curl-format.txt" -s -o /dev/null http://yourdomain.com/
Enter fullscreen mode Exit fullscreen mode

My curl-format.txt shows timing breakdown:

time_namelookup:  %{time_namelookup}\n
time_connect:     %{time_connect}\n
time_appconnect:  %{time_appconnect}\n
time_total:       %{time_total}\n
Enter fullscreen mode Exit fullscreen mode

Resource Usage Reality Check

This setup uses about 2GB RAM for two Node.js containers plus Nginx on my 4GB Vultr VPS. CPU usage stays under 20% during deployments.

Your mileage varies based on application size and deployment frequency.

Why This Beats Kubernetes

Kubernetes offers similar capabilities but requires 3+ nodes for true high availability. That's $120+ monthly versus my $40 VPS.

For small businesses, this Docker approach provides 99.9% uptime without the complexity tax.

Your Next Steps

Start with health checks in your existing application. Add the /health endpoint this week. Set up the blue-green containers next weekend. Deploy the Nginx configuration after testing locally.

You'll sleep better knowing your deployments don't wake up angry customers.

Top comments (0)