What blue-green and rolling deploys mean for a single monolith

A blue-green deployment keeps two complete production environments for the same monolith. One environment, blue, serves all traffic. The other, green, receives the new build, starts up and passes health checks while it is idle. Release is a single traffic switch: the load balancer, DNS record or router moves every request from blue to green at once. Blue stays running so you can switch back.

A rolling deploy uses one pool. The orchestrator takes a batch of instances out of rotation, replaces them with the new build, waits for them to pass health checks, and then moves on to the next batch until every instance runs the new version.

 BLUE-GREEN                          ROLLING
        [ LB ]                              [ LB ]
       /      \                        /   |    |   \
 [BLUE v1]  [GREEN v2]              [v2] [v2] [v1] [v1]
  active     idle, warm             one pool, mid-rollout

Both strategies aim for a zero downtime release, but they get there differently. Blue-green relies on the fact that a fully started environment is ready before any user reaches it. Rolling relies on the fact that enough capacity stays in service at every step.

Neither strategy avoids running two versions at the same time:

  • Rolling: old and new instances serve requests side by side for the whole rollout.
  • Blue-green: both versions run during the switch, while in-flight requests on blue drain, and for as long as blue stays warm for rollback.
  • Background workers: in either strategy, workers, schedulers and queue consumers on the old build can keep processing jobs after the new build starts.
      BLUE-GREEN                                ROLLING
   (two complete environments)            (one pool, replaced in batches)

        +----------+                           +----------+
        |    LB    |                           |    LB    |
        +----------+                           +----------+
          |      :                              |  |  |  |
    100%  |      :  0%                          v  v  v  v
          v      v                       +----------------------+
  +-----------+ +-----------+            | +--+ +--+ +--+ +--+  |
  |  BLUE v1  | | GREEN v2  |            | |v2| |v2| |v1| |v1|  |
  |  active   | | idle, warm|            | +--+ +--+ +--+ +--+  |
  | +--+ +--+ | | +--+ +--+ |            |  new  new  old  old  |
  | |v1| |v1| | | |v2| |v2| |            +----------------------+
  | +--+ +--+ | | +--+ +--+ |              mixed v1/v2 serve live
  +-----------+ +-----------+              traffic for the rollout
        |             |                             |
        +------+------+                             |
               v                                    v
        +-------------+                      +-------------+
        |  shared DB  |                      |  shared DB  |
        |  and queue  |                      |  and queue  |
        +-------------+                      +-------------+
  one switch moves 100% to green         batches swap until all are v2
  v1 workers can keep consuming jobs     v1 and v2 workers consume jobs
  while blue stays warm for rollback     side by side during the rollout

Blue-green keeps two complete environments behind the load balancer and switches all traffic at once, while a rolling deploy replaces instances in batches within one pool; both run v1 and v2 against the same database and queue for a period of time.

The real constraint: two versions against one shared database

Whichever tool moves traffic, both strategies face the same fact: for some window, two builds read and write the same database. That window breaks releases far more often than the load balancer does.

The standard defence is expand/contract, spread across several releases:

  1. Expand: add the new shape additively, such as a nullable email_normalized column. Don't drop, rename or tighten anything.
  2. Handle both shapes: ship code that writes both columns and reads the new one, falling back to the old.
  3. Backfill: populate existing rows in batches, decoupled from the deploy.
  4. Contract: in a later release, once no running build reads the old column, drop it.

This is why a blue-green rollback is only instant if no destructive or non-backward-compatible migration has run. Pointing traffic back at blue achieves nothing if the column blue queries was renamed. Additive changes can still bite. If green writes data blue doesn't understand, such as a new enum value or a status blue's code has no branch for, blue fails after rollback on the data, not on the schema.

The same rule applies outside the database:

  • Caches: version cache keys whenever the serialized format changes. Otherwise old instances deserialize new entries and crash.
  • APIs: keep contracts backward compatible for clients that hit either version mid-release.
  • Queues: consumers must read messages from both producer versions in any order.

Blue-green confines mixed-version exposure to the switch and rollback window. A rolling deploy runs mixed versions in production for the entire rollout.

-- Release N (expand): additive only. Old builds ignore the new column.
-- A nullable column with no default is a metadata-only change in PostgreSQL.
ALTER TABLE users ADD COLUMN email_normalized text;

-- Release N app code (not SQL): write both email and email_normalized,
-- read email_normalized and fall back to email when it is NULL.

-- Backfill: a separate job, not part of the deploy. Run it repeatedly
-- until it updates 0 rows. Small batches keep each lock short.
UPDATE users
SET email_normalized = lower(trim(email))
WHERE id IN (
  SELECT id
  FROM users
  WHERE email_normalized IS NULL
    AND email IS NOT NULL
  ORDER BY id
  LIMIT 1000
);

-- Release N+1: every running build now writes email_normalized,
-- so tightening the constraint cannot reject writes from a live build.
-- (SET NOT NULL scans the table to validate existing rows.)
ALTER TABLE users ALTER COLUMN email_normalized SET NOT NULL;

-- Release N+2 (contract): only after no deployed build, and no build
-- you might roll back to, reads or writes the old column.
-- After this runs, rolling back to release N or earlier is no longer instant.
ALTER TABLE users DROP COLUMN email;

An expand/contract migration spread across releases: add a nullable column, backfill it in batches outside the deploy, tighten the constraint once every running build writes it, and drop the old column only after no running or rollback-target build depends on it.

How the traffic switch works in blue-green

The switch itself can live in three places. A load balancer target group swap repoints the listener from the blue group to the green group. The change is effectively atomic for new requests, and you can reverse it in seconds. A reverse proxy upstream change works the same way: you edit which upstream is active, reload, and the proxy routes new requests to green while workers serving existing connections finish. The accompanying config shows one active and one standby upstream, and switching between them is a one-line edit. A DNS change is the weakest option. Resolvers cache records, and some clients, including long-running JVMs and embedded HTTP libraries, ignore TTLs entirely. The result is a slow migration with no clear end, and your rollback has to wait out those same caches.

Whichever mechanism you use, enable connection draining so in-flight requests on blue complete instead of being reset. Websockets and other long-lived connections will not drain by themselves. You need to leave blue running until those clients disconnect, or send them a close frame so they reconnect to green.

Before the switch, run smoke tests against green through a private listener or header-based route. Green should hit the production database, cache and queues, not a staging copy. Don't rely on liveness checks alone. A monolith often returns 200 before its caches, connection pools and JIT are warm, so add a readiness check that exercises real code paths.

Finally, budget for database connections. During the overlap, blue and green both hold full pools, which can double usage and exhaust the server's connection limit at exactly the moment you are switching traffic.

# Two upstream groups, one per environment. Both stay defined at all times.
upstream blue {
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    keepalive 32;
}
upstream green {
    server 10.0.2.10:8080;
    server 10.0.2.11:8080;
    keepalive 32;
}

# Send Upgrade only for websocket requests. Other requests get an empty
# Connection header, which keeps upstream keepalive working.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

# Public listener. To switch, edit ONE line and run 'nginx -t && nginx -s reload'.
# Old workers keep serving their open connections (including websockets)
# until those connections close, so blue must stay up until they drain.
server {
    listen 80;
    location / {
        proxy_pass    # ACTIVE environment: change to green to switch
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;  # allow long-lived websocket connections
    }
}

# Private listener on an internal address. It always points at the standby
# environment, so you can smoke test green against production dependencies.
server {
    listen 10.0.0.5:8081;
    location / {
        proxy_pass   # STANDBY environment
        proxy_http_version 1.1;
        proxy_set_header Host $host;
    }
}

A minimal nginx config with blue and green upstreams, where the public listener routes to the active one, switching is a one-line edit plus reload, and a private internal listener exposes the standby for pre-switch smoke tests.

How rolling deploys work in practice

A rolling deploy avoids that doubling by replacing instances in batches. Two settings control the pace: the batch size, and a budget for either surge (extra new instances started before old ones stop) or max-unavailable (old instances stopped before replacements are ready). For a monolith, every instance serves every route, so taking one of four offline removes a quarter of total capacity. If you have no headroom, use surge.

Readiness and liveness checks do different jobs. Readiness decides whether an instance gets traffic. Liveness decides whether it gets restarted. A monolith that takes minutes to boot needs a liveness check that tolerates startup, or the orchestrator will kill it in a loop. A common mistake is a readiness endpoint that returns success as soon as the HTTP listener binds, before the connection pool, migrations check or caches are in place. Failures then surface only after several batches, when much of the fleet is already on the new version. Make readiness exercise real dependencies, and pause after the first batch to watch error rates.

Warm-up shows up as latency spikes. JIT compilation and cold in-memory caches make each new batch slow at first. If the new version changes cache keys or contents, hit rates can stay low for longer than they did on the old version.

Sessions and shutdown. Sticky sessions tie users to instances that are about to disappear. An externalized session store removes that coupling. On SIGTERM, an instance should do four things in order:

  1. Fail readiness.
  2. Wait for the load balancer to notice.
  3. Stop accepting new requests.
  4. Finish in-flight work within a drain timeout shorter than the orchestrator's kill deadline.

The example below follows this sequence.

const http = require('http');

// Delay + drain timeout must stay under the orchestrator's kill deadline (assumed 30s here).
const LB_NOTICE_DELAY_MS = 5000; // >= readiness probe period x failure threshold
const DRAIN_TIMEOUT_MS = 20000;
let ready = true;

const server = http.createServer((req, res) => {
  // Liveness: the process is up. It does not depend on draining state.
  if (req.url === '/live') return res.end('ok');
  // Readiness: should this instance receive traffic?
  if (req.url === '/ready') {
    res.statusCode = ready ? 200 : 503;
    return res.end(ready ? 'ok' : 'draining');
  }
  res.end('application response'); // real routes go here
});
server.listen(8080);

process.on('SIGTERM', () => {
  // 1. Fail readiness so the load balancer stops routing here.
  ready = false;
  // 2. Give the load balancer time to observe the failing probe.
  setTimeout(() => {
    // 3. Stop accepting new connections. The callback runs once in-flight requests finish.
    server.close(() => process.exit(0));
    // Keep-alive sockets with no active request would otherwise hold the server open.
    server.closeIdleConnections();
    // 4. Hard drain deadline: exit before the orchestrator sends SIGKILL.
    setTimeout(() => process.exit(1), DRAIN_TIMEOUT_MS).unref();
  }, LB_NOTICE_DELAY_MS);
});

A Node.js HTTP server handles SIGTERM in four steps: it fails readiness, waits for the load balancer to notice, stops accepting new connections, and lets in-flight requests finish before a hard drain timeout that is shorter than the orchestrator's kill deadline.

Rollback, singleton jobs and feature flags compared

Blue-green rollback is a traffic switch back to the old environment. It takes seconds and restores every user at once, but only while that environment is still running and its schema expectations still match the database. If you tear down blue as soon as green looks healthy, you lose this advantage. A rolling rollback is another rolling deploy of the previous artifact. It takes roughly as long as the forward rollout, and until it finishes, some instances still serve the bad version.

Background work is where both strategies break. A cron scheduler or singleton job inside the monolith starts on every instance that boots. During a blue-green overlap, it runs in both environments. During a rolling deploy, it runs on old and new instances together. If an instance is killed mid-run, the job can be dropped. Queue consumers in two versions can also process the same message types differently. To prevent this, use leader election or a database lock for singletons, or tie job start and stop to the active version. In blue-green, only the environment receiving traffic should run schedulers.

Feature flags separate deploy from release. You ship the code dark and enable it later. During an incident, turning off a flag takes effect without rebuilding, redeploying or waiting for a rollout. That saves engineers the slowest part of either rollback path.

Dimension Blue-green Rolling
Rollback speed Seconds (traffic switch) About as long as the forward rollout
Failure blast radius All users at once Grows as more instances are replaced
Mixed-version window Short, at the switch The entire rollout
Job coordination needs Gate jobs to the active environment Locking or leader election across instances

Impact on cost and engineering time

Illustrative estimate (assumed numbers, not a benchmark). Assume 10 instances per environment and a connection pool of 20 per instance.

  • Peak instances = steady instances + extra instances during the deploy
  • Peak DB connections = peak instances × pool size per instance
Strategy Peak instances Peak DB connections Ongoing extra capacity
Blue-green 10 + 10 = 20 20 × 20 = 400 Up to 10 instances if the idle side stays warm
Rolling, surge of 2 10 + 2 = 12 12 × 20 = 240 None after the rollout ends

The compute peak in blue-green is short if you tear down the old side after the switch. Many teams keep it running so they can roll back instantly, which doubles the fleet permanently. The database is usually the harder limit. It must accept 400 connections without hitting its connection cap or running out of memory, even though normal traffic needs 200. You have to provision for that ceiling, not for the average.

Development cost is the same for both strategies, and it tends to be larger than the infrastructure bill. Every release must be backward compatible with the previous version. Schema changes need a transitional phase: add the new column, write to both old and new, backfill, switch reads, then drop the old column in a later release. Feature flags need removal tickets. Teams underestimate this work because it is spread across many small pull requests and never shows up as a single line item.

Each strategy saves time in a different place:

  • Blue-green saves time during incidents. Rollback is a traffic switch rather than a redeploy.
  • Rolling saves ongoing operational effort. There is only one environment to patch, monitor and keep consistent.

Decision guide: when to use each, when to avoid it, and alternatives

Choose by matching your constraints to what each strategy assumes.

Condition Favor Why
Rollback must take seconds, not a redeploy Blue-green The previous version keeps running and only needs traffic back
Release is risky or large Blue-green You can smoke-test the new stack before any user reaches it
Spare capacity for a second full environment Blue-green Its main cost is already covered
Migration drops or rewrites columns Neither as-is Neither strategy fixes a schema the old version can't read. Split the migration first
Capacity is tight Rolling It needs one or two extra instances, not a duplicate fleet
Small, frequent releases Rolling Lower per-release overhead, and each change is small enough to roll forward
Mixed versions are unsafe Blue-green The cutover is brief, but old and new still share the database

Blue-green's switch does not remove the shared-database problem. In-flight requests and background workers can still overlap at cutover. A rollback also returns traffic to code that must read whatever the new version wrote. Rolling makes the overlap longer and visible. It is a poor fit when one version cannot tolerate the other's writes, or when rollback means waiting for a reverse rollout.

The middle ground

Canary-style partial traffic shifting sends a small share of requests to the new version and watches error rates and latency before increasing the share. It limits how many users a bad release reaches. It still requires mixed-version compatibility, and it needs a load balancer that supports weighted routing and metrics good enough to judge the canary.

Recommendation

Make mixed-version compatibility the baseline. Use expand-and-contract migrations, tolerant readers and flags around behavioral changes. Once that holds, the choice is mostly about cost. Pick blue-green if instant rollback is worth paying for a second environment. Pick rolling if it isn't.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to monolith blue green deployment, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.