Blog

Building a Zero-Downtime Application

Published

June 30, 2026

Author

pratham chauhan

Type

Insights Article

Reading Time

11 min

Deploying software to production is easy. Deploying it without interrupting users is where things get hard.

My application is a Spring Boot microservices platform: a React frontend, an API gateway, a backend service, PostgreSQL, and Keycloak for authentication. Early on, deployments were simple build Docker images, swap the running containers, and hope everything came back online.

As the application matured, the cracks became obvious. Users experienced downtime whenever containers restarted. If a deployment failed halfway through, rolling back meant manually rebuilding the previous environment. Infrastructure services like PostgreSQL and Keycloak were entangled with application deployments changing one line in the backend could trigger a Keycloak restart. Every release carried more operational risk than it needed to.

I wanted a deployment strategy that could:

  • deploy a new release without affecting active users
  • verify health before switching traffic
  • switch traffic instantly once a release was validated
  • automatically recover if the new release became unhealthy
  • keep shared infrastructure stable across deployments
  • support canary rollouts later without redesigning everything.

No Kubernetes. I’ll explain why.


Why Not Kubernetes

Kubernetes is usually the first recommendation for blue-green deployments. For this workload, it was the wrong choice.

ParametersDocker ComposeKubernetes
EnvironmentSingle VMCluster of nodes
Operational overheadLow — one config file per stackHigh — controllers, operators, CRDs
Debuggingdocker logs, docker inspectkubectl, multiple namespaces, contexts
Blue-green supportManual but fully controllableNative via Deployments + Services
Fit for this workloadGood — predictable single-VM trafficOverkill
When to reconsiderWhen you outgrow one VMWhen you need multi-node resilience

The Core Architecture

The fundamental insight that made everything else possible: separate infrastructure from application services, and run two application releases simultaneously.

Two Compose Projects

Instead of one large docker-compose.yml containing everything, I split the environment into two independent Compose projects.

Platform stack long-lived infrastructure that rarely changes:

  • PostgreSQL
  • Keycloak database
  • Keycloak

Application stack services that change with every release:

  • Backend
  • API Gateway
  • Frontend

Deploying a new backend version should never restart the authentication server. Keeping them in separate projects enforces this cleanly.

/infra/
  platform/
    docker-compose.yml        # PostgreSQL + Keycloak
  app/
    docker-compose.mainline.yml
    docker-compose.beta.yml

Two Application Releases at Once

Both mainline and beta application stacks run simultaneously, sharing platform infrastructure via a common Docker network.

# docker-compose.mainline.yml
networks:
  platform-net:
    external: true

services:
  backend-mainline:
    image: ${REGISTRY}/backend:${MAINLINE_TAG}
    container_name: backend-mainline
    networks: [platform-net]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 12
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb

  gateway-mainline:
    image: ${REGISTRY}/gateway:${MAINLINE_TAG}
    container_name: gateway-mainline
    ports:
      - "8103:8080"
    networks: [platform-net]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 12

  frontend-mainline:
    image: ${REGISTRY}/frontend:${MAINLINE_TAG}
    container_name: frontend-mainline
    ports:
      - "8101:80"
    networks: [platform-net]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/health"]
      interval: 10s
      timeout: 5s
      retries: 6
# docker-compose.beta.yml -- same structure, different ports and container names
services:
  backend-beta:
    image: ${REGISTRY}/backend:${BETA_TAG}
    container_name: backend-beta
    networks: [platform-net]
    # no port published -- only reachable through gateway

  gateway-beta:
    image: ${REGISTRY}/gateway:${BETA_TAG}
    container_name: gateway-beta
    ports:
      - "28103:8080"
    networks: [platform-net]

  frontend-beta:
    image: ${REGISTRY}/frontend:${BETA_TAG}
    container_name: frontend-beta
    ports:
      - "28101:80"
    networks: [platform-net]

The active release is determined entirely by Nginx. Docker just keeps both stacks alive.

                  Internet
                      │
                      ▼
                   Nginx
                      │
         ┌────────────┴────────────┐
         │                         │
   Mainline Stack             Beta Stack
   :8101  :8103             :28101  :28103
         └────────────┬────────────┘
                      │
        Shared Platform Services
        PostgreSQL + Keycloak

Traffic Routing with Nginx

Running two stacks is the easy half. The harder problem: routing traffic between them without restarting Nginx or changing DNS.

Permanent Upstreams

Both release environments are permanently defined. Deployments never touch upstream configuration.

# /etc/nginx/conf.d/upstreams.conf

upstream app_frontend_mainline {
    server 127.0.0.1:8101;
}
upstream app_frontend_beta {
    server 127.0.0.1:28101;
}

upstream app_gateway_mainline {
    server 127.0.0.1:8103;
}
upstream app_gateway_beta {
    server 127.0.0.1:28103;
}

Release Selection — Priority Order

Every request resolves to one value: mainline or beta. The decision follows a strict priority chain:

Incoming Request
       │
       ▼
 X-Release header?  ──yes──▶  Use header value
       │ no
       ▼
 ?release= param?   ──yes──▶  Use query value
       │ no
       ▼
 Release cookie?    ──yes──▶  Use cookie value
       │ no
       ▼
 Traffic bucket     ──────▶   Default (mainline)
# /etc/nginx/conf.d/release-routing.conf

# 1. Explicit header override (highest priority)
map $http_x_release $release_from_header {
    "beta"     "beta";
    "mainline" "mainline";
    default    "";
}

# 2. Query parameter override
map $arg_release $release_from_query {
    "beta"     "beta";
    "mainline" "mainline";
    default    "";
}

# 3. Sticky cookie
map $cookie_app_release $release_from_cookie {
    "beta"     "beta";
    "mainline" "mainline";
    default    "";
}

# 4. Traffic bucket (default: 100% mainline)
split_clients "${remote_addr}${http_user_agent}" $app_release_bucket {
    # 20% beta;   # uncomment for canary rollout
    * mainline;
}

# Resolve final release -- first non-empty value wins
map "$release_from_header:$release_from_query:$release_from_cookie" $app_release {
    ~^beta      "beta";
    ~^:beta     "beta";
    ~^::beta    "beta";
    default     $app_release_bucket;
}

Header override — QA sends X-Release: beta to access the candidate release. Normal users are unaffected. No separate URL needed.

Query overridehttps://app.example.com?release=beta. Useful for mobile testing and external tools where header injection is awkward.

Sticky cookie — Once a release is selected, Nginx writes a cookie so subsequent requests in the same session stay on the same environment. Without this, a user could bounce between releases across page loads.

Traffic bucket — The fallback. Designed with canary deployment in mind from day one. Enabling 20% beta traffic later is a single config change — no application code, no Docker changes, no workflow modifications.

Server Block

server {
    listen 443 ssl;
    server_name app.example.com;

    # Frontend routing
    location / {
        set $upstream_frontend "app_frontend_${app_release}";
        proxy_pass http://$upstream_frontend;

        # Write sticky cookie
        add_header Set-Cookie "app_release=${app_release}; Path=/; SameSite=Lax";

        # Automatic failover
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
    }

    # API routing
    location /api/ {
        set $upstream_gateway "app_gateway_${app_release}";
        proxy_pass http://$upstream_gateway;

        add_header Set-Cookie "app_release=${app_release}; Path=/; SameSite=Lax";

        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
    }
}

Automatic Failover

proxy_next_upstream handles the case where the selected release becomes unhealthy mid-request. If beta returns a 502/503/504, Nginx transparently retries using mainline. The user gets a response; the release cookie updates so future requests avoid the broken environment.

This matters most in the first few minutes after promotion, when you’re watching logs and hoping nothing breaks.


GitHub Actions Deployment Pipeline

Repository Layout

.github/
  workflows/
    platform-deploy.yml    # PostgreSQL + Keycloak -- runs rarely
    app-deploy.yml         # Build + deploy beta
    app-promote.yml        # Promote beta → mainline (manual trigger)

Deployment Timeline

Every release follows the same sequence. Nothing is skipped.

Code Push / Tag
      │
      ▼
Build Docker Images
      │
      ▼
Push to Artifact Registry
      │
      ▼
Deploy Beta Stack
(mainline untouched)
      │
      ▼
Health Checks
(all 3 services must pass)
      │
      ├── FAIL → Pipeline stops, mainline stays active
      │
      ▼
Beta running, validated
      │
   [manual]
      │
      ▼
Promote Beta → Mainline
      │
      ▼
Users receive new release

Application Deploy Workflow

# .github/workflows/app-deploy.yml
name: Deploy Application

on:
  push:
    tags: ['v*']

env:
  REGISTRY: us-central1-docker.pkg.dev/${{ vars.GCP_PROJECT }}/app

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker us-central1-docker.pkg.dev

      - name: Extract tag
        id: meta
        run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

      - name: Build and push backend
        run: |
          docker build -t $REGISTRY/backend:${{ steps.meta.outputs.version }} ./backend
          docker push $REGISTRY/backend:${{ steps.meta.outputs.version }}

      - name: Build and push gateway
        run: |
          docker build -t $REGISTRY/gateway:${{ steps.meta.outputs.version }} ./gateway
          docker push $REGISTRY/gateway:${{ steps.meta.outputs.version }}

      - name: Build and push frontend
        run: |
          docker build -t $REGISTRY/frontend:${{ steps.meta.outputs.version }} ./frontend
          docker push $REGISTRY/frontend:${{ steps.meta.outputs.version }}

  deploy-beta:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy beta to production VM
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VM_HOST }}
          username: deploy
          key: ${{ secrets.VM_SSH_KEY }}
          script: |
            cd /opt/app

            export BETA_TAG=${{ needs.build.outputs.image_tag }}
            export REGISTRY=${{ env.REGISTRY }}
            docker compose -f docker-compose.beta.yml pull

            # Recreate beta containers only - mainline untouched
            docker compose -f docker-compose.beta.yml up -d --force-recreate

            echo "$BETA_TAG" > .beta-tag

  health-check:
    needs: deploy-beta
    runs-on: ubuntu-latest
    steps:
      - name: Wait for beta to become healthy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VM_HOST }}
          username: deploy
          key: ${{ secrets.VM_SSH_KEY }}
          script: |
            check_health() {
              local service=$1
              local url=$2
              local retries=24
              local wait=5

              echo "Waiting for $service..."
              for i in $(seq 1 $retries); do
                if curl -sf "$url" > /dev/null 2>&1; then
                  echo "$service is healthy"
                  return 0
                fi
                echo "  attempt $i/$retries - sleeping ${wait}s"
                sleep $wait
              done

              echo "$service failed health check after $((retries * wait))s"
              return 1
            }

            check_health "backend-beta"  "http://localhost:8080/actuator/health"
            check_health "gateway-beta"  "http://localhost:28103/actuator/health"
            check_health "frontend-beta" "http://localhost:28101/health"

            echo "All beta services healthy. Ready for promotion."

At this point, production traffic is still 100% on mainline. Beta is running, healthy, and waiting.

Promotion Workflow – Separate, Manual

# .github/workflows/app-promote.yml
name: Promote Beta to Mainline

on:
  workflow_dispatch:   # manual trigger only
    inputs:
      confirm:
        description: 'Type PROMOTE to confirm'
        required: true

jobs:
  promote:
    runs-on: ubuntu-latest
    if: ${{ github.event.inputs.confirm == 'PROMOTE' }}
    steps:
      - name: Promote beta → mainline
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VM_HOST }}
          username: deploy
          key: ${{ secrets.VM_SSH_KEY }}
          script: |
            cd /opt/app

            export MAINLINE_TAG=$(cat .beta-tag)
            envsubst < docker-compose.mainline.yml.tpl > docker-compose.mainline.yml

            docker compose -f docker-compose.mainline.yml up -d --force-recreate

            for i in $(seq 1 24); do
              if curl -sf http://localhost:8103/actuator/health > /dev/null 2>&1; then
                echo "Mainline promoted successfully to $MAINLINE_TAG"
                exit 0
              fi
              sleep 5
            done

            echo "Mainline failed to start - traffic still on previous release"
            exit 1

The promotion workflow is triggered manually from GitHub Actions after I’ve spent time validating beta – checking API responses, running smoke tests, verifying logs.


What Happened the First Time It Saved Me

Three months into running this setup, I promoted a release that had a bug in the JWT token validation logic. The gateway was rejecting tokens with a non-standard aud claim that worked fine in the previous version but broke after a library upgrade I hadn’t caught in testing.

Here’s exactly what happened:

14:30 – Promotion workflow ran. Mainline updated to v1.4.2.

14:34 – First user report: login returning 401. I checked gateway-mainline logs:

WARN  o.s.s.web.DefaultSecurityFilterChain - JWT validation failed:
  Invalid audience claim. Expected [api], got [api, mobile]

Immediately clear – the new gateway was rejecting tokens the old one accepted.

14:35 – Checked that gateway-beta (now the old version, still running) was still healthy:

curl -sf http://localhost:28103/actuator/health | jq .status
# "UP"

14:36 – Updated .mainline-tag back to v1.4.1, ran promotion in reverse:

export MAINLINE_TAG=v1.4.1
docker compose -f docker-compose.mainline.yml up -d --force-recreate

14:38 – Mainline healthy again. Login working. Nginx continued routing to mainline throughout no config changes needed.

Eight minutes from first report to resolution. No database restore. No emergency image rebuild. The previous release was sitting right there.

Before this setup, the same incident would have been: identify bug, find the commit, rebuild image, push to registry, redeploy, wait for Spring Boot startup. Probably 25–30 minutes during which users couldn’t log in.


Observing the Deployment

Blue-green deployment without visibility is just hope with extra steps. Three sources cover the operational picture without adding infrastructure.

Docker Health Status

# See health state of all app containers at a glance
docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "mainline|beta"

# NAMES                  STATUS
# frontend-mainline      Up 3 hours (healthy)
# gateway-mainline       Up 3 hours (healthy)
# backend-mainline       Up 3 hours (healthy)
# frontend-beta          Up 12 minutes (healthy)
# gateway-beta           Up 12 minutes (healthy)
# backend-beta           Up 12 minutes (starting)

The (starting) status tells you a container passed its first check but hasn’t reached the full retry threshold yet. This is why the health-check script waits for all services explicitly rather than trusting Docker’s “running” status.

Nginx Access Logs

Once beta is promoted, the access log is the fastest way to confirm traffic switched:

tail -f /var/log/nginx/access.log | grep --line-buffered "app_release"

# 203.0.113.42 - [14:38:01] "GET /api/user" 200 - cookie: app_release=mainline
# 198.51.100.7 - [14:38:02] "GET /api/orders" 200 - cookie: app_release=mainline

Adding $cookie_app_release to the Nginx log format makes it trivial to see which release is serving each request and spot any unexpected routing.

Spring Boot Actuator

Each service exposes /actuator/health with component-level detail:

curl -s http://localhost:8080/actuator/health | jq .

# {
#   "status": "UP",
#   "components": {
#     "db": { "status": "UP" },
#     "keycloak": { "status": "UP" },
#     "diskSpace": { "status": "UP" }
#   }
# }

A gateway that is “UP” overall but has a downstream component in “DOWN” state is a problem that Docker’s container-level health check would miss. The actuator endpoint catches it.


Secrets and Configuration

Production config is injected through GitHub Secrets and Variables – never stored in the repository.

# Injected at deploy time, not committed
environment:
  SPRING_DATASOURCE_URL: ${{ secrets.DB_URL }}
  SPRING_DATASOURCE_PASSWORD: ${{ secrets.DB_PASSWORD }}
  KEYCLOAK_CLIENT_SECRET: ${{ secrets.KC_CLIENT_SECRET }}
  JWT_SECRET: ${{ secrets.JWT_SECRET }}
  STORAGE_BUCKET: ${{ vars.GCP_STORAGE_BUCKET }}
  SMTP_HOST: ${{ vars.SMTP_HOST }}
  SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}

The repository contains deployment logic. Not production credentials.


Nginx Config – Transactional Updates

For the rare infrastructure changes that do require Nginx config updates, I wrote a script that treats the update transactionally:

#!/bin/bash
# nginx-update.sh

NGINX_CONF="/etc/nginx/conf.d/release-routing.conf"
NGINX_CONF_BACKUP="${NGINX_CONF}.bak"

# Backup current working config
cp "$NGINX_CONF" "$NGINX_CONF_BACKUP"

# Apply new config
cp "$1" "$NGINX_CONF"

# Validate before touching the live server
if ! nginx -t 2>/dev/null; then
  echo "Nginx config validation failed - restoring backup"
  cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
  nginx -t && echo "Backup restored successfully"
  exit 1
fi

# Safe to reload
nginx -s reload
echo "Nginx updated successfully"

If validation fails, the previous working config is restored automatically. This has already caught a broken upstream block during an infrastructure change before it reached the live server.


Lessons That Actually Mattered

Separate infrastructure from applications. PostgreSQL and Keycloak should never restart because you changed a Spring Boot endpoint. Once I split the Compose projects, deployments became far less risky.

Deployment and promotion are different operations. Deploy prepares a release. Promotion exposes it. The gap between those two steps is where you validate. Collapsing them into one step is where most deployment anxiety comes from.

Health checks, not container status. Docker reports a container as “running” long before the Spring Boot application is ready. Use healthcheck in Compose and poll the actuator endpoint explicitly before considering a deployment complete.

Keep the previous release alive. The entire value of rollback depends on not destroying mainline when you deploy beta. If you tear it down before validating the new release, you’ve eliminated your escape route.

Nginx doesn’t need to change during deployments. Both upstreams are permanently configured. Deploying a new version changes containers, not routing config. This made the system significantly simpler to reason about under pressure.

Log which release is serving each request. Adding $cookie_app_release to the Nginx log format was a 5-minute change that saved hours during the JWT incident. Know which environment your traffic is hitting before something breaks, not after.

We use cookies to enhance your experience, analyze site traffic and deliver personalized content. Learn more about who we are, how you can contact us, and how we process personal data in our Privacy Policy.