Sitemap
Ashu Gupta

Ashu Gupta

Zero-Downtime Java Deployments on a Single VM Without Docker or Kubernetes

5 min readJul 29, 2026

--

How to achieve true 100% Blue/Green deployments for Spring Boot using native systemd, Nginx, and Bash — saving gigabytes of RAM in the process(For Sandbox/Dev/UAT environment only).

Every backend engineer has been there: you push a new version of your Java/Spring Boot service, your CI/CD pipeline stops the running process, starts the new .jar, and for the next 30 to 60 seconds... BOOM. Your users hit 502 Bad Gateway or 504 Gateway Timeout screens.

The standard modern response? “Just throw it on Kubernetes!” or “Spin up a Docker Swarm cluster!”

But what if you are running on a single Virtual Machine? Adding Kubernetes (or even K3s) just to deploy a single Java application introduces 1 to 1.8 GB of idle memory overhead and continuous CPU polling loops — just for control-plane infrastructure.

If you want a lightweight, production-grade, 100% zero-downtime deployment mechanism that utilizes 0% background CPU and almost zero RAM overhead, you can build it using tools already built into Linux: systemd, Nginx, and Bash.

Here is how to set it up step-by-step.

The Architecture: How It Works

Instead of running one service that shuts down during an update, we maintain two identical systemd services on the same VM running on alternate ports:

  • Blue Instance: Runs on port 8085
  • Green Instance: Runs on port 8086

Nginx sits in front as a Reverse Proxy & Load Balancer. During a deployment:

  1. The pipeline identifies the idle instance (e.g., Green on 8086).
  2. The new .jar is started on Green.
  3. The pipeline polls the Spring Boot /actuator/health endpoint until Green returns 200 OK.
  4. Nginx updates its upstream target to 8086 and reloads gracefully (nginx -s reload).
  5. In-flight requests on Blue (8085) finish gracefully before Blue is stopped.
Press enter or click to view image in full size

Step 1: Configure Spring Boot for Graceful Shutdown

Before configuring Nginx, we must ensure Spring Boot finishes active user requests before shutting down.

Get Ashu Gupta’s stories in your inbox

Join Medium for free to get updates from this writer.

Add these properties to your src/main/resources/application.properties:

# Enable graceful shutdown
server.shutdown=graceful

# Maximum time Spring Boot will wait for active requests to complete
spring.lifecycle.timeout-per-shutdown-phase=20s

Step 2: Create Systemd Services for Blue and Green

Create two separate systemd unit files on your Linux VM to manage each instance independently.

  1. /etc/systemd/system/app-blue.service
[Unit]
Description=Java App - Blue Instance
After=network.target

[Service]
User=ubuntu
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/java -jar -Dserver.port=8085 /var/www/app/app.jar
SuccessExitStatus=143
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

2. /etc/systemd/system/app-green.service

[Unit]
Description=Java App - Green Instance
After=network.target

[Service]
User=ubuntu
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/java -jar -Dserver.port=8086 /var/www/app/app.jar
SuccessExitStatus=143
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Reload systemd daemon after saving:

sudo systemctl daemon-reload

Step 3: Configure Nginx Dynamic Upstream

Nginx will point to a dedicated include file that specifies the active port.

Main Config: /etc/nginx/conf.d/app.conf

include /etc/nginx/conf.d/active_backend.inc;

server {
listen 80;
server_name _;

location / {
proxy_pass http://backend_app;

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;

# Keepalive setup to prevent socket overhead
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}

Active Port File: /etc/nginx/conf.d/active_backend.inc

Create this initial file:

upstream backend_app {
server 127.0.0.1:8085;
}

Step 4: The Zero-Downtime Deployment Script

Save this script as /home/ubuntu/deploy.sh and make it executable (chmod +x /home/ubuntu/deploy.sh).

#!/bin/bash
set -e

APP_DIR="/var/www/app"
HEALTH_CHECK_PATH="/actuator/health"

# 1. Detect currently active port
CURRENT_PORT=$(grep -oE '808[56]' /etc/nginx/conf.d/active_backend.inc || echo "8085")

if [ "$CURRENT_PORT" == "8085" ]; then
NEW_COLOR="green"
NEW_PORT="8086"
OLD_COLOR="blue"
OLD_PORT="8085"
else
NEW_COLOR="blue"
NEW_PORT="8085"
OLD_COLOR="green"
OLD_PORT="8086"
fi

echo "=========================================="
echo "Active instance: $OLD_COLOR ($OLD_PORT)"
echo "Target deployment: $NEW_COLOR ($NEW_PORT)"
echo "=========================================="

# 2. Start target service with the new JAR version
echo "-> Starting app-$NEW_COLOR service..."
sudo systemctl restart app-$NEW_COLOR

# 3. Poll health check until app is fully operational
echo "-> Waiting for app-$NEW_COLOR on port $NEW_PORT to report UP..."
MAX_RETRIES=30
COUNT=0
HEALTHY=0

while [ $COUNT -lt $MAX_RETRIES ]; do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:$NEW_PORT$HEALTH_CHECK_PATH || true)

if [ "$HTTP_STATUS" -eq 200 ]; then
HEALTHY=1
break
fi

sleep 2
COUNT=$((COUNT+1))
echo " Checking health status ($COUNT/$MAX_RETRIES)..."
done

if [ $HEALTHY -eq 0 ]; then
echo "❌ ERROR: New deployment on port $NEW_PORT failed health check. Aborting!"
sudo systemctl stop app-$NEW_COLOR
exit 1
fi

# 4. Atomic Nginx Cutover
echo "-> App is healthy! Updating Nginx upstream to $NEW_COLOR ($NEW_PORT)..."
echo "upstream backend_app { server 127.0.0.1:$NEW_PORT; }" | sudo tee /etc/nginx/conf.d/active_backend.inc > /dev/null

echo "-> Reloading Nginx..."
sudo nginx -t && sudo systemctl reload nginx

echo "-> Traffic successfully cut over to $NEW_COLOR!"

# 5. Graceful draining & shutdown of old service
echo "-> Draining active connections on $OLD_COLOR (waiting 10s)..."
sleep 10

echo "-> Stopping app-$OLD_COLOR service..."
sudo systemctl stop app-$OLD_COLOR

echo "=========================================="
echo "✅ 100% Zero-Downtime Deployment Complete!"
echo "=========================================="

Step 5: Connecting to Azure DevOps Pipeline

Here is an example azure-pipelines.yml snippet that builds your project, uploads the artifact over SSH, and triggers the deployment.

stages:
- stage: Deploy
jobs:
- job: SSHDeploy
steps:
- task: CopyFilesOverSSH@0
inputs:
sshEndpoint: 'My-VM-SSH-Connection'
sourceFolder: '$(Pipeline.Workspace)/drop'
contents: '*.jar'
targetFolder: '/var/www/app'
overwrite: true

- task: SSH@0
inputs:
sshEndpoint: 'My-VM-SSH-Connection'
runOptions: 'inline'
inline: |
set -e
/home/ubuntu/deploy.sh

Why Is This Better Than Kubernetes for Single VM Setups?

Press enter or click to view image in full size

Why Cutover Is Instant & Zero-Downtime

When sudo systemctl reload nginx is invoked, Nginx sends a SIGHUP signal to its master process.

Nginx instantly spawns new worker processes with the new upstream address configuration while keeping old worker processes alive to finish serving active TCP connections. Zero connections are dropped, and zero socket errors are returned.

Conclusion

Container orchestrators are amazing when managing distributed microservices across multiple worker nodes. But if your application runs on a single Virtual Machine, you don’t need the complexity or memory weight of Docker Swarm or Kubernetes just to prevent downtime during updates.

By leveraging systemd for process supervision, Nginx for master/worker socket swapping, and Spring Boot’s graceful shutdown, you get true 100% zero-downtime deployments — with zero wasted RAM.

Did you find this architecture useful? Follow me on Medium for more practical Java, DevOps, and backend performance insights!

--

--