00:00

What Is Blue-Green Deployment? Zero-Downtime Releases Explained

Last updated:

Blue-green deployment is a release strategy that runs two identical production environments, called blue and green. One serves all live users while the new version is installed and tested on the other; then traffic is switched to the new environment in one step. If something goes wrong, you switch traffic back, which makes rollback almost instant.

Releasing new code is one of the riskiest moments in a service's life. Blue-green deployment turns that moment into a simple, reversible switch instead of a nervous, all-or-nothing update.

1. The Problem With Updating in Place

Imagine a cloud-kitchen ordering service. Every evening between 7 and 10 pm, thousands of people order dinner. The simplest release process is to stop the old version, install the new one and start it again. This is called an in-place or recreate deployment.

It has two big problems:

  • Downtime. While the application restarts, which can take a minute for a large Spring Boot service, users see errors.
  • Slow rollback. If the new version has a bug, you must redeploy the old version, which takes just as long and happens while users are already affected.

Blue-green deployment removes both problems by never touching the environment that is serving users.

2. How Blue-Green Deployment Works

An environment here means a complete running copy of the application: its servers or pods, configuration and runtime. The two copies are labelled with colours only to tell them apart. Neither colour means "old" or "new"; they swap roles with every release.

Blue-green deploymentUsers reach a router or load balancer, which is the switch. The router sends live traffic to the blue environment running version 2.3.0. The green environment runs version 2.4.0 and is idle while the release team smoke tests it through a private URL; after the switch the router sends traffic to green instead. Both environments use the same shared database. Users Router / load balancer the switch Blue environment v2.3.0 (live) Green environment v2.4.0 (idle, being tested) Shared database Release team live traffic after the switch smoke tests via private URL
The router sends all users to blue while green, running the new version, is tested privately; switching the router moves every user to green at once.

A release goes through five steps:

  1. Deploy to the idle side. Blue is live with v2.3.0. You deploy v2.4.0 to green. Users are not affected, because no live traffic reaches green.
  2. Test green privately. The team runs smoke tests, quick checks that the main features work, against green through a private URL.
  3. Warm up. Send some synthetic traffic so caches fill and the JVM optimises hot code paths.
  4. Switch. Change the router so all new requests go to green. This takes seconds.
  5. Keep blue on standby. Leave blue running for a while. If green misbehaves, switch back. Once you are confident, blue becomes the idle side for the next release.

Notice that both environments usually share the same database. Copying a live database for every release is rarely practical, and this shared state is the source of the trickiest problems, as Example 3 shows.

3. Where the Switch Happens

The "switch" can live at several levels:

  • Load balancer: change the target group behind a cloud load balancer or an Nginx upstream.
  • Kubernetes Service: run two Deployments labelled color: blue and color: green, and change the Service's label selector to point at the other one.
  • DNS: point the domain name at a new address. This is the slowest and least reliable option, because clients cache DNS answers. The JVM, for example, caches successful lookups for 30 seconds by default when no security manager is installed.
  • Tools: Argo Rollouts, AWS CodeDeploy and similar tools automate the steps, including the switch and the rollback.

4. Code Examples

These JDK-only Java 21 programs run real HTTP servers on your machine to show the switch, the draining problem and the database trap.

4.1 Example 1: The Switch and the Rollback

BlueGreenSwitchDemo.java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;

public class BlueGreenSwitchDemo {

    static void send(HttpExchange ex, int status, String body) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        ex.sendResponseHeaders(status, bytes.length);
        try (OutputStream out = ex.getResponseBody()) {
            out.write(bytes);
        }
    }

    // One environment: a complete copy of the app, running one version
    static HttpServer environment(String color, String version) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
        server.createContext("/api/menu", ex -> send(ex, 200, "menu v" + version + " from " + color));
        server.createContext("/health", ex -> send(ex, 200, "UP"));
        server.start();
        return server;
    }

    static String url(HttpServer s) {
        return "http://localhost:" + s.getAddress().getPort();
    }

    static String get(HttpClient client, String url) throws Exception {
        var resp = client.send(HttpRequest.newBuilder(URI.create(url)).build(), HttpResponse.BodyHandlers.ofString());
        return resp.statusCode() + " " + resp.body();
    }

    public static void main(String[] args) throws Exception {
        HttpServer blue = environment("blue", "2.3.0");
        HttpServer green = environment("green", "2.4.0");
        var live = new AtomicReference<>(url(blue)); // the single switch: where live traffic goes

        try (HttpClient client = HttpClient.newHttpClient()) {
            // The router (load balancer) forwards every user request to the live environment
            HttpServer router = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
            router.createContext("/", ex -> {
                try {
                    String answer = get(client, live.get() + ex.getRequestURI());
                    send(ex, 200, answer.substring(4));
                } catch (Exception e) {
                    send(ex, 502, "bad gateway");
                }
            });
            router.start();
            String site = url(router) + "/api/menu";

            System.out.println("user -> " + get(client, site));
            System.out.println("smoke test green directly: " + get(client, url(green) + "/api/menu")
                    + ", health " + get(client, url(green) + "/health"));

            live.set(url(green)); // the switch: one atomic change, no redeploy
            System.out.println("== switched live traffic to green ==");
            System.out.println("user -> " + get(client, site));

            live.set(url(blue)); // rollback is the same switch in reverse
            System.out.println("== bug reported, rolled back to blue ==");
            System.out.println("user -> " + get(client, site));
            router.stop(0);
        } finally {
            blue.stop(0);
            green.stop(0);
        }
    }
}
Output
user -> 200 menu v2.3.0 from blue
smoke test green directly: 200 menu v2.4.0 from green, health 200 UP
== switched live traffic to green ==
user -> 200 menu v2.4.0 from green
== bug reported, rolled back to blue ==
user -> 200 menu v2.3.0 from blue

The whole release is controlled by one AtomicReference. Green was tested directly before any user reached it, and the rollback was the same one-line change in reverse. No servers were restarted.

4.2 Example 2: In-Flight Requests and Draining

Switching the router only affects new requests. Requests that are already running on blue must be allowed to finish before blue is stopped. This is called connection draining, and most blue-green tutorials skip it.

DrainDemo.java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;

public class DrainDemo {

    static void send(HttpExchange ex, int status, String body) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        ex.sendResponseHeaders(status, bytes.length);
        try (OutputStream out = ex.getResponseBody()) {
            out.write(bytes);
        }
    }

    static String url(HttpServer s) {
        return "http://localhost:" + s.getAddress().getPort();
    }

    static void run(int drainSeconds) throws Exception {
        var started = new CountDownLatch(1);
        HttpServer blue = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
        blue.createContext("/api/invoice", ex -> {
            started.countDown();
            try {
                Thread.sleep(600); // a slow request: generating a PDF invoice
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            send(ex, 200, "invoice.pdf from blue");
        });
        blue.start();
        HttpServer green = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
        green.createContext("/api/invoice", ex -> send(ex, 200, "invoice.pdf from green"));
        green.start();

        var live = new AtomicReference<>(url(blue));
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpServer router = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
            router.createContext("/", ex -> {
                try {
                    var resp = client.send(HttpRequest.newBuilder(URI.create(live.get() + ex.getRequestURI())).build(),
                            HttpResponse.BodyHandlers.ofString());
                    send(ex, resp.statusCode(), resp.body());
                } catch (IOException | InterruptedException e) {
                    send(ex, 502, "bad gateway (connection to blue was cut)");
                }
            });
            router.start();

            var inFlight = client.sendAsync(HttpRequest.newBuilder(URI.create(url(router) + "/api/invoice")).build(),
                    HttpResponse.BodyHandlers.ofString());
            started.await(); // blue is now busy with the request

            live.set(url(green));
            blue.stop(drainSeconds); // 0 = kill now; N = wait up to N seconds for running requests
            var resp = inFlight.join();
            System.out.printf("stop(%d): in-flight request -> %d %s%n", drainSeconds, resp.statusCode(), resp.body());
            router.stop(0);
        } finally {
            green.stop(0);
        }
    }

    public static void main(String[] args) throws Exception {
        run(0);
        run(3);
    }
}
Output
stop(0): in-flight request -> 502 bad gateway (connection to blue was cut)
stop(3): in-flight request -> 200 invoice.pdf from blue

With stop(0), blue was shut down while it was still generating an invoice, and the user got a 502 error even though the switch itself worked perfectly. With stop(3), blue refused new connections but waited for the running request to finish. In Spring Boot, this is graceful shutdown: server.shutdown=graceful (the default since Spring Boot 3.4), with a limit set by spring.lifecycle.timeout-per-shutdown-phase (30 seconds by default).

4.3 Example 3: The Database Trap During Rollback

SchemaRollbackDemo.java
import java.util.LinkedHashMap;
import java.util.Map;

public class SchemaRollbackDemo {

    // One shared table row; blue and green both use the same database
    static Map<String, String> customerRow() {
        var row = new LinkedHashMap<String, String>();
        row.put("id", "C-31");
        row.put("phone", "+91 98450 11223");
        return row;
    }

    static String blueV1ReadsPhone(Map<String, String> row) {
        String phone = row.get("phone");
        return phone == null ? "ERROR: column \"phone\" does not exist" : "SMS sent to " + phone;
    }

    static void run(String title, boolean expandContract) {
        System.out.println(title);
        Map<String, String> row = customerRow();

        // Green v2 deploys and migrates the schema before the switch
        if (expandContract) {
            row.put("mobile", row.get("phone")); // EXPAND: add the new column, keep the old one
        } else {
            row.put("mobile", row.remove("phone")); // breaking: rename in one step
        }
        System.out.println("  green v2 live, row = " + row);

        // A user updates their number while green is live
        row.put("mobile", "+91 77600 99881");
        if (expandContract) {
            row.put("phone", row.get("mobile")); // v2 still writes the old column too
        }

        // Rollback: blue v1 takes traffic again
        System.out.println("  rolled back to blue v1: " + blueV1ReadsPhone(row));
    }

    public static void main(String[] args) {
        run("Rename in one step:", false);
        run("Expand and contract:", true);
    }
}
Output
Rename in one step:
  green v2 live, row = {id=C-31, mobile=+91 98450 11223}
  rolled back to blue v1: ERROR: column "phone" does not exist
Expand and contract:
  green v2 live, row = {id=C-31, phone=+91 98450 11223, mobile=+91 98450 11223}
  rolled back to blue v1: SMS sent to +91 77600 99881

Rollback is only instant if the old version can still work with the database. When green renamed phone to mobile in one step, blue could no longer read the data, so the "instant rollback" was broken. With expand and contract, green adds the new column while keeping the old one and writes to both, so blue keeps working. Only after blue is retired does a later release contract the schema by dropping phone.

5. Comparison Table: Deployment Strategies

RecreateRollingBlue-greenCanary
How it worksStop old, start newReplace instances a few at a timeSwitch all traffic between two full environmentsSend a small share of traffic to the new version first
DowntimeYesNoNoNo
Two versions serving at onceNoYes, during the rolloutNo (except during the switch)Yes, by design
Rollback speedSlow (redeploy)Slow (roll back again)Seconds (switch back)Fast (send 0% to new)
Extra capacity neededNoneA littleDouble, during the releaseA little
Users exposed to a bad releaseAllGrowing shareAll, after the switchOnly the canary share

6. Common Mistakes and Pitfalls

1. Breaking database changes. As Example 3 showed, a migration that the old version can't handle turns a quick rollback into an outage. Every schema change must be backward compatible for at least one release.

2. Stopping blue too early. Without draining, every request still running on blue at shutdown fails. Wait for in-flight work to finish, or for the graceful-shutdown timeout.

3. Long-lived connections stay on blue. Changing a Kubernetes Service selector or a load balancer target only affects new connections. Existing HTTP keep-alive connections, WebSockets and gRPC streams can keep talking to blue until they close. Close them gracefully on blue, or make clients reconnect.

4. A cold JVM taking full traffic. A freshly started JVM runs slower until the JIT compiler has optimised the busy code and caches are filled. Switching 100% of peak traffic to a cold green can cause a latency spike, so warm it up first.

5. Background work running twice. If blue and green both run @Scheduled jobs or consume the same Kafka topic, the idle side may process real work before it is "live". Disable background processing on the idle environment, or use a distributed lock such as ShedLock.

6. In-memory sessions. Users logged in on blue lose their session after the switch if sessions live in server memory. Store sessions externally, for example with Spring Session and Redis, or use stateless tokens.

7. Best Practices

  • Automate the whole flow: deploy, smoke test, warm up, switch, monitor and roll back.
  • Make every schema change backward compatible with expand and contract.
  • Enable graceful shutdown and keep blue running for a safety window after the switch.
  • Watch error rates and latency closely for the first minutes after the switch, with an automatic rollback rule if they cross a limit.
  • Keep both environments identical in configuration and size, generated from the same templates.
  • Prefer a load balancer or Service switch over DNS, because DNS caching makes switching slow and uneven.
  • Scale the idle side down between releases if cost matters, and scale it up again before the next deployment.

8. Summary

  • Blue-green deployment runs two identical environments and switches live traffic from one to the other in a single step.
  • The idle environment is deployed and tested privately, so users never see a half-finished release.
  • Rollback is just switching back, as long as the database is still compatible with the old version.
  • The switch only affects new requests and connections: drain in-flight work and handle long-lived connections.
  • Warm up the new JVM, stop background jobs on the idle side, and keep sessions outside the application.
  • Blue-green needs double capacity during a release; canary releases limit exposure but take longer.

Used carefully, blue-green deployment makes releases boring in the best way: fast, reversible and invisible to your users.