Blue-Green Deployment, Rolling Deployment, and Safe Database Migrations

Explain blue-green and rolling deployment strategies, understand why both require backward-compatible database schema changes during the transition window, and apply the expand/contract migration pattern to change a schema without downtime.

Deploying a new version of a PHP application to a small number of servers used to mean an unavoidable moment of downtime: stop the old code, replace the files, start the new code, and hope nothing important happened to a request that landed in that gap. Modern deployment strategies exist specifically to eliminate that gap — but they introduce a subtler problem that trips up teams who adopt zero-downtime deploys without also rethinking how they change their database schema. This lesson covers two of the most common zero-downtime deployment strategies, blue-green and rolling, why each one implies a period where old and new application code run simultaneously against the same database, and the expand/contract pattern that makes schema changes safe during that window.

Blue-Green Deployment

Blue-green deployment maintains two complete, independent production environments — conventionally labeled 'blue' and 'green' — where at any given time exactly one of them (say, blue) is live, receiving all real traffic, while the other (green) is idle or being prepared. To deploy a new version, you deploy it entirely onto the idle environment (green), run whatever smoke tests, health checks, or even a slice of synthetic traffic against it to confirm it's genuinely healthy, and only then flip a single switch at the load balancer or router level that redirects all live traffic from blue to green.

The single biggest advantage of blue-green deployment is the speed and simplicity of rollback: if something goes wrong after the traffic switch — an error rate spike, a critical bug that testing missed — rolling back means flipping the same switch back to point at blue, which is still fully intact, fully warmed up, and running the previous known-good version. This is dramatically faster and lower-risk than a rollback strategy that requires redeploying old code from scratch, because the old environment was never touched or torn down.

The cost of blue-green is resource duplication: you need enough infrastructure capacity to run two complete production environments simultaneously (even if one is briefly idle), which roughly doubles the infrastructure footprint during any deploy, and can be a meaningful ongoing cost depending on how the idle environment is kept warm between deploys. Blue-green also switches all traffic essentially at once, which is precise and fast but means a subtle bug that only manifests under real production load and scale gets exposed to 100% of traffic immediately upon the switch, rather than being caught while affecting only a small fraction of users.

• Two full environments (blue, green); one live, one idle/staging at any time. • Deploy to the idle environment, verify it's healthy, then switch all traffic at the load balancer/router. • Rollback is just switching back — the previous environment was never modified or torn down. • Cost: roughly double the infrastructure footprint; traffic moves all-at-once rather than gradually.

Rolling Deployment

Rolling deployment takes the opposite approach to traffic movement: rather than two separate environments, there is one fleet of servers or container instances (or Kubernetes pods) behind a load balancer, and the new version is rolled out by replacing a subset of that fleet at a time — take one instance out of rotation, update it to the new version, health-check it, put it back into rotation, then move to the next instance, repeating until the entire fleet is running the new version. At virtually every point during the rollout, some fraction of instances are running the old version and some fraction the new version, simultaneously serving real production traffic.

This has the opposite trade-off profile from blue-green: rolling deployment needs no duplicate infrastructure (you're reusing the same fleet, just replacing it piecewise), and a bad deploy is caught while it's only affecting a fraction of traffic — if the first few updated instances start erroring, the rollout can be paused or reversed before every instance has been touched, limiting the blast radius considerably compared to blue-green's all-at-once switch. Kubernetes' Deployment resource implements rolling updates as its default update strategy, replacing pods gradually according to maxSurge and maxUnavailable settings that control how much extra capacity or reduced capacity is tolerated mid-rollout.

The trade-off is that rollback is not an instant switch the way it is in blue-green — reverting means running the same rolling process in reverse, replacing instances back to the old version one at a time, which takes time proportional to the fleet size rather than being instantaneous. It also means, for the entire duration of the rollout (which can be minutes for a small fleet or considerably longer for a very large one), both old and new code are genuinely live in production concurrently, not just briefly in a health-check window — a fact with direct consequences for how database changes must be handled, covered next.

• One fleet; instances/pods are replaced with the new version a few at a time, not all at once. • No duplicate infrastructure needed, unlike blue-green. • A bad deploy affects only the already-updated fraction of the fleet and can be paused early. • Rollback is itself a gradual process (reverse rolling update), not an instant switch.

Why Both Strategies Demand Backward-Compatible Database Changes

The property both strategies share — and the one most teams underestimate before their first real zero-downtime deploy — is that for some window of time, old application code and new application code are both running against the exact same database, simultaneously, serving real traffic. In blue-green, this window is usually short (during health checks before the switch, and briefly during rollback if it happens) but real; in rolling deployment, this window is the entire duration of the rollout, and is unavoidable by design.

This means a database migration cannot simply assume 'the old code is gone the moment the migration runs.' If a migration renames a column, drops a column the old code still reads or writes, or changes a column's meaning or type in a way the old code doesn't expect, then for as long as any old-code instance is still live and hitting that database, it will start throwing errors or corrupting data — a rolling deployment in particular guarantees this happens in production, to real users, not in some theoretical edge case, because old and new code coexist by design for the whole rollout.

The practical implication is that a schema change tied to a deploy must be broken into pieces such that the schema, at every intermediate state, works correctly against both the old and the new application code simultaneously — this constraint doesn't go away by picking blue-green over rolling or vice versa; it's inherent to any deployment strategy that avoids a hard, all-at-once cutover with downtime.

The Expand/Contract Migration Pattern

Expand/contract (also called 'parallel change') is the standard pattern for making a schema change safely across a deploy boundary, and it works by splitting a single conceptual schema change into three or more separate, individually safe deploys instead of one atomic one. The 'expand' phase adds the new schema shape alongside the old one without removing anything — a new column, a new table, a new index — so that the old code, unaware of the addition, continues working exactly as before, untouched by the new column's mere existence.

The 'migrate/backfill' phase deploys application code that writes to both the old and new schema shapes simultaneously (for a rename, this typically means writing the same value to both the old and new column on every write) and runs a backfill process — a script or migration job — to populate the new shape for all pre-existing rows that were written before this deploy. During this phase both old-shape and new-shape data are kept in sync and both are valid to read, which is exactly what makes it safe for old and new code to coexist: neither one is looking at stale or missing data for the other's expectations.

The 'contract' phase, deployed only once every instance in the fleet is confirmed to be running code that no longer depends on the old shape, is what finally removes the old column, table, or index — this is the step that would have broken old code, so it must strictly come last, and only after enough time (and confidence) has passed that no rollback to the pre-migration code is anticipated. Each of these phases is independently deployable and independently safe at every intermediate point, which is exactly the property a rolling or blue-green deploy requires.

• Expand: add the new column/table/index; old code is unaffected because nothing old was removed. • Migrate/backfill: new code writes to both old and new shapes; a backfill job populates the new shape for existing rows. • Contract: remove the old shape, only after every instance is confirmed running code that no longer needs it. • Each phase must independently work correctly if left running for an extended, indefinite time.

Rollback Strategy: Decoupling Code Rollback from Database Rollback

A direct consequence of designing migrations this way is that reverting application code becomes independent of reverting the database — if the new code from a rolling deploy turns out to be buggy, rolling back to the previous code version should work correctly against the database's current schema state, without requiring a separate 'undo the migration' step run in a hurry under incident pressure. This is only true, though, if the expand phase was genuinely additive and the old code was never made to depend on the new shape being absent.

This is precisely why 'can we roll back the code without needing a matching database rollback' is one of the most useful review questions to ask before merging any migration alongside application code changes — a migration that only works if paired with a specific application code version, deployed atomically together, defeats the entire purpose of a rolling or blue-green strategy, because there is no instant, atomic way to deploy both an application version and a schema version together across a fleet that updates gradually.

In practice, teams adopting this discipline keep the contract phase of any given migration as a separate, later deploy — often days or weeks after the expand/migrate phases, once they're confident no rollback to the old code is coming — rather than bundling all three phases into one release. The extra deploys are a small, predictable cost compared to the alternative: an incident where a rollback to fix a broken new version is itself blocked or made worse because the database was already changed in a way only the new code understands.

Expand/Contract for a Column Rename (Three Deploys)

Deploy 1 and 2 must each be safe to run indefinitely — no time pressure to rush to Deploy 3. Application code changes accompany each deploy: Deploy 2's code writes to both columns; a later deploy (before Deploy 3) switches reads to email_address only.

-- Deploy 1 (expand): add the new column, old column untouched
ALTER TABLE users ADD COLUMN email_address VARCHAR(255) NULL;

-- Deploy 2 (migrate/backfill): app code now writes to BOTH columns;
-- a backfill job copies existing data into the new column
UPDATE users SET email_address = email WHERE email_address IS NULL;

-- Deploy 3 (contract): only after all instances run code that reads
-- exclusively from email_address, drop the old column
ALTER TABLE users DROP COLUMN email;

Code Examples

During the migrate/backfill phase, every write updates both the old (email) and new (email_address) columns so that either an old-code instance (still reading/writing email) or a new-code instance (reading/writing email_address) sees correct, up-to-date data regardless of which version happens to serve the next request. Once the contract deploy removes the old column, this dual-write code is also removed in the same or a preceding deploy — leaving it in place after the column is gone would cause every write to fail.

maxSurge: 1 allows one extra pod above the desired replica count during the rollout, and maxUnavailable: 1 allows one pod to be offline at a time, so at most two of six pods are 'in flux' at once — the other four continue serving traffic on whichever version they currently run, which is exactly the old/new coexistence window that demands backward-compatible database changes.

maxSurge: 1 / maxUnavailable: 1

<?php
function updateUserEmail(PDO $db, int $userId, string $newEmail): void {
    // Write to BOTH the old and new column while both are in use.
    $stmt = $db->prepare(
        'UPDATE users SET email = :email, email_address = :email WHERE id = :id'
    );
    $stmt->execute(['email' => $newEmail, 'id' => $userId]);
}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: php-app
spec:
  replicas: 6
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  template:
    spec:
      containers:
        - name: php-app
          image: registry.example.com/php-app:2.4.0
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080

Blue-Green vs Rolling: Traffic Over Time

Rolling deployment guarantees an extended old/new coexistence window; blue-green's window is short but still real during health checks and rollback.

Blue-Green:                         Rolling:
  100% blue ──┐                       100% old
              │ instant switch        90% old / 10% new
  100% green ─┘                       50% old / 50% new
  (old env intact for rollback)       10% old / 90% new
                                       100% new
                                       (gradual; old+new coexist throughout)

Important Rules

Never let a single deploy both change the database schema in a breaking way and require that change for correctness — split into expand, migrate/backfill, and contract deploys.

Design every intermediate migration state to work correctly if left running indefinitely, not just for a few minutes.

Only run the contract phase after every fleet instance is confirmed to no longer depend on the old schema shape.

A code rollback should never require a matching, hastily-run database rollback — verify this before merging any migration.

Treat a rolling deployment's old/new coexistence window as guaranteed and continuous, not a rare edge case.

Why Large Platforms Standardize on Expand/Contract

Companies running large fleets behind rolling or canary deployments treat 'no migration may break the currently-deployed old code' as a hard review requirement, not a suggestion, precisely because at their scale a rollout can take from minutes to hours, guaranteeing a long window where multiple code versions query the same production database concurrently.

ALTER TABLE users RENAME COLUMN email TO email_address; -- deployed atomically with new code that only knows email_address

Split into expand (add email_address), migrate (dual-write + backfill), and a later contract (drop email) deploy.

A single atomic rename breaks every old-code instance still running during a rolling deploy the instant the migration runs, because those instances still query the now-nonexistent email column.

Bundling a breaking schema change with the application code that depends on it in one release, betting the rollout finishes before anyone rolls back

Keep the contract phase in a separate, later deploy, only after confirming the fleet is fully on the new code with no rollback expected

If a rollback becomes necessary mid-rollout or shortly after, a breaking schema change bundled into the same release makes rollback impossible without an emergency, high-pressure database fix — the entire point of the expand/contract split is to avoid ever being in that position.

What is the primary advantage of blue-green deployment's rollback over rolling deployment's rollback? A. Blue-green doesn't need a load balancer B. Blue-green rollback is an instant switch back to an untouched, already-running previous environment, versus a gradual reverse rollout C. Blue-green never requires database migrations D. Blue-green uses less infrastructure than rolling deployment

B. Blue-green rollback is an instant switch back to an untouched, already-running previous environment, versus a gradual reverse rollout — Because the previous (blue) environment was never modified or torn down, rolling back is just flipping the traffic switch back — instantaneous — whereas rolling deployment's rollback is itself a gradual process of replacing instances again, taking time proportional to fleet size.

Why does a rolling deployment specifically guarantee a database compatibility challenge that a hard cutover deploy would not? A. Rolling deployments don't use databases B. Old and new code versions are both live in production simultaneously for the entire duration of the rollout, by design C. Rolling deployments always require more database servers D. Rolling deployments disable the database during the rollout

B. Old and new code versions are both live in production simultaneously for the entire duration of the rollout, by design — Unlike a hard cutover (which has downtime but never runs two code versions against the database at once), a rolling deployment guarantees a real window — the whole rollout duration — where a mix of old and new instances query the same database concurrently.

In the expand/contract pattern, why must the 'contract' phase (removing the old column) be deployed last, and separately? A. Because SQL syntax requires DROP COLUMN to run after ALTER TABLE ADD COLUMN B. Because removing the old shape would break any still-running old code that depends on it, so it must wait until no such code remains live C. Because databases process DROP statements more slowly D. There's no real reason; it's just convention

B. Because removing the old shape would break any still-running old code that depends on it, so it must wait until no such code remains live — The whole purpose of splitting the migration into phases is to ensure every intermediate schema state works for both old and new code; removing the old column is the one step that breaks old code, so it must only happen once no old-code instance is left running.

What review question best captures the goal of designing migrations around blue-green/rolling deployment? A. Does the migration run fast enough? B. Can the application code be rolled back without requiring a matching, urgent database rollback? C. Does the migration use the fewest possible SQL statements? D. Is the migration written in the same language as the application?

B. Can the application code be rolled back without requiring a matching, urgent database rollback? — The core safety property expand/contract provides is that a code rollback works against the current database state without needing an emergency schema rollback — that decoupling is the practical goal to check for before merging a migration.

Describe, as a three-step expand/contract plan, how you would safely change a status column from storing the strings 'active'/'inactive' to storing integers 1/0, across a rolling deployment.

1) Expand: ALTER TABLE items ADD COLUMN status_code TINYINT NULL. 2) Migrate/backfill: deploy code that writes both status ('active'/'inactive') and status_code (1/0) on every update, and backfill status_code for existing rows based on the current status value; once all instances read status_code, deploy code that only reads status_code. 3) Contract: once confirmed no rollback is needed, ALTER TABLE items DROP COLUMN status.

Add a new integer column first; have the app write both representations during migrate/backfill; only drop the old string column once all instances read the integer column.