← Back to Blog

How to Migrate an Existing Host Nginx Setup to SafeDeployer with Zero Downtime

A step-by-step guide to upgrading your existing VPS host Nginx configuration (SSL certificates, WebSockets, and multi-environment setups) to SafeDeployer zero-downtime blue/green deployments.

By SafeDeployer Engineering Team
6 min read
How to Migrate an Existing Host Nginx Setup to SafeDeployer with Zero Downtime

A very common production architecture across web applications and SaaS backends is running Nginx directly on a VPS host machine (such as a DigitalOcean Droplet, AWS EC2, or Hetzner VPS). In this setup, host Nginx terminates SSL certificates (via Let’s Encrypt / Certbot), proxies traffic to local Docker containers, and handles WebSockets and REST APIs.

Traditionally, backend deployments on these servers require rebuilding a container or running docker compose up -d, resulting in temporary downtime, dropped WebSocket connections, and 502 Bad Gateway errors during the swap.

In this guide, we’ll walk through how to migrate your existing host Nginx setup to SafeDeployer for zero-downtime Blue/Green deployments—while preserving all your existing Let’s Encrypt SSL certificates, domain routing, and multi-environment isolation (e.g., Dev/Test and Production running side-by-side).


How the Architecture Changes

Aspect❌ Traditional Hardcoded Setup✅ SafeDeployer Dynamic Setup
Proxy TargetStatic host port (e.g., proxy_pass http://localhost:3001;)Named upstream block (e.g., proxy_pass http://dev_api_upstream;)
Deployment SwapContainer is torn down and recreated (downtime & 502s)New container boots in parallel on dynamic port, passes health checks, proxy hot-reloads
SSL / Let’s EncryptBound to host NginxUntouched; host Nginx continues managing certificates seamlessly
RollbacksManual container rollbackAutomated instant rollback if new container fails health checks

Step 1: Create Dedicated Upstream Files & Permissions

Because Nginx is installed directly on your host OS, SafeDeployer writes the dynamic upstream configuration directly to the host filesystem.

When managing multiple services or environments (such as Dev/Test and Production) on the same host, create separate upstream configuration files inside /etc/nginx/conf.d/ for each environment:

# 1. For Dev / Test environment
sudo touch /etc/nginx/conf.d/dev-api-upstream.conf
sudo chown $USER:$USER /etc/nginx/conf.d/dev-api-upstream.conf
sudo chmod 664 /etc/nginx/conf.d/dev-api-upstream.conf

# 2. For Production environment (if on the same VPS)
sudo touch /etc/nginx/conf.d/prod-api-upstream.conf
sudo chown $USER:$USER /etc/nginx/conf.d/prod-api-upstream.conf
sudo chmod 664 /etc/nginx/conf.d/prod-api-upstream.conf

Note on Permissions: Assigning ownership to your deployment user ($USER:$USER) allows SafeDeployer to update upstreams during CI/CD pushes without requiring root permissions for file writes.

Seed Initial Placeholder Upstreams

To ensure nginx -t passes before your first SafeDeployer deployment runs, seed each file with your current running container port:

/etc/nginx/conf.d/dev-api-upstream.conf:

upstream dev_api_upstream {
    server 127.0.0.1:3001;
}

/etc/nginx/conf.d/prod-api-upstream.conf:

upstream prod_api_upstream {
    server 127.0.0.1:3000;
}

(Debian/Ubuntu /etc/nginx/nginx.conf automatically includes include /etc/nginx/conf.d/*.conf; in the global http context by default).


Step 2: Update Your Existing Nginx Site Configuration

Open your site configuration file (e.g. /etc/nginx/sites-available/test-api.my-app.com or /etc/nginx/sites-available/dev-api).

Replace all instances of proxy_pass http://localhost:3001; with proxy_pass http://dev_api_upstream;. Your SSL certificates, WebSocket upgrade headers, and custom routes remain completely untouched:

server {
    server_name dev-api.my-app.com;
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/api.my-app.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.my-app.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        return 444;
    }

    # REST API Endpoint
    location /api/v1/ {
        proxy_pass http://dev_api_upstream;
        proxy_http_version 1.1;
        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_cache_bypass $http_upgrade;
    }

    # WebSockets / Socket.io / Real-Time Relays
    location /socket.io/ {
        proxy_pass http://dev_api_upstream;
        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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }

    # Custom WebSocket or Relay Endpoints
    location /vynrelay/ {
        proxy_pass http://dev_api_upstream;
        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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }

    # API Documentation / Swagger
    location /api/docs {
        proxy_pass http://dev_api_upstream;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

server {
    if ($host = dev-api.my-app.com) {
        return 301 https://$host$request_uri;
    }
    server_name dev-api.my-app.com;
    listen 80;
    return 444;
}

Validate and reload Nginx:

sudo nginx -t && sudo systemctl reload nginx

Step 3: Configure SafeDeployer in docker-compose.yml

In your project repository, add the x-safedeployer configuration block with is_host: true:

services:
  my-app-test-api:
    image: my-app-test-api:latest
    ports:
      - "127.0.0.1:3001:3001"
    restart: always

x-safedeployer:
  project_name: "my-app-test-api"
  health_check:
    path: "/api/v1/health"
    timeout_seconds: 5
    interval_seconds: 3
    retries: 15
  router:
    provider: "nginx"
    is_host: true                                 # Instructs SafeDeployer to manage host Nginx
    upstream_name: "dev_api_upstream"             # Must match the proxy_pass target in Nginx
    config_path: "/etc/nginx/conf.d/dev-api-upstream.conf" # Direct path on host OS

Step 4: Grant Passwordless Sudo Reloads for CI/CD

When deploying via automated CI/CD pipelines (such as GitHub Actions SSH actions with an ubuntu or deploy user), Nginx reload requires elevated privileges to re-read root-protected Let’s Encrypt SSL certificates (0700 permissions on /etc/letsencrypt/live).

Configure passwordless sudo Nginx reload once on your VPS:

echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, /bin/systemctl reload nginx" | sudo tee /etc/sudoers.d/safedeployer-nginx
sudo chmod 0440 /etc/sudoers.d/safedeployer-nginx

Step 5: Automate via CI/CD (GitHub Actions)

In your GitHub Actions workflow (.github/workflows/deploy.yml), invoke SafeDeployer using sd-deploy up:

name: Deploy Test API

on:
  push:
    branches: [ staging, dev ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            # 1. Ensure CLI is in PATH
            export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"

            # 2. Install SafeDeployer if not already present
            if ! command -v sd-deploy &> /dev/null; then
              curl -fsSL https://safedeployer.com/api/install | bash
            fi

            cd /home/deploy/apps/test-backend

            # 3. Build latest container image
            docker compose -f docker-compose.test.yml build my-app-test-api

            # 4. Trigger zero-downtime blue/green deployment
            export SAFEDEPLOYER_API_TOKEN="${{ secrets.SAFEDEPLOYER_API_TOKEN }}"
            sd-deploy up --config docker-compose.test.yml --token "${{ secrets.SAFEDEPLOYER_API_TOKEN }}"
            
            # Clean up old dangling images
            docker image prune -f

How SafeDeployer Executes Zero-Downtime Swaps

When sd-deploy up runs:

  1. Parallel Provisioning: SafeDeployer identifies the inactive color (green) and starts the new container on an allocated local port (e.g., 127.0.0.1:3002).
  2. Health Verification: SafeDeployer polls /api/v1/health until the application confirms it is fully booted and ready.
  3. Atomic Upstream Switch: SafeDeployer overwrites /etc/nginx/conf.d/dev-api-upstream.conf with server 127.0.0.1:3002; and triggers a zero-downtime Nginx reload.
  4. Graceful Teardown: The old blue container is cleanly stopped after in-flight requests finish processing.
  5. Multi-Environment Safety: The production environment (/etc/nginx/conf.d/prod-api-upstream.conf) remains completely isolated and untouched.

Summary

Migrating from a hardcoded host Nginx proxy to SafeDeployer takes less than 5 minutes and requires zero architectural overhauls. You keep your existing VPS setup, Let’s Encrypt certificates, and Docker Compose files, while gaining automated blue/green switches, zero downtime releases, and automatic rollback protection.


Discussion