← All articles
AWSCloudFrontblue-greenOpenTofuTerragruntDevOps

AWS Blue/Green Deployment with CloudFront Continuous Deployment, OpenTofu, and Terragrunt

Joakim Bengtsson

The scariest part of shipping to production is usually the way back. A deploy goes out, something looks wrong, and the team improvises a rollback under pressure while customers watch. Blue/green deployment removes that pressure. You keep two complete copies of your application running side by side, send production traffic to one, and switch by moving a pointer. If the new copy misbehaves, you move the pointer back.

This article walks through a working demo that does exactly that on AWS, for a full vertical slice: a single-page app in S3, an HTTP API on API Gateway, and a Lambda behind it. It is managed with OpenTofu modules, Terragrunt explicit stacks, and CloudFront continuous deployment. You will see the core configuration; the glue is left out on purpose.

The technical challenge

Switching a single service between two versions is well understood. Switching a whole application is trickier, because several things have to stay coordinated:

  • The frontend and backend belong to one slot. Each color bundles a UI bucket and its API, so a switch moves a consistent pair and the page and API always agree.
  • The public surface has to stay stable. Your domain, TLS certificate, and CloudFront settings should not churn on every cutover. Rebuilding them invites downtime and certificate headaches.
  • You want to test the new version on the real domain first. Staging rarely matches production exactly. Validating through the real production front door, before customers reach it, catches what a separate staging URL hides.
  • Your infrastructure state has to stay honest. Cut over with a console click or an untracked API call and your state drifts, so the next apply tries to “fix” reality back and can undo your release.

The demo handles all four. Here is how.

The solution

One stable public entry point, two color targets

CloudFront continuous deployment works with two distributions: a primary and a staging one. The primary distribution is your stable public entry point. All production traffic enters through it, and its domain and TLS certificate stay fixed.

Each color is a complete slot: a private S3 bucket served through CloudFront with Origin Access Control (OAC), plus an API Gateway and Lambda. The primary distribution points its origins at the active color’s slot. The staging distribution serves the inactive color so you can validate it through the production domain (covered below).

Switching colors re-points the primary’s origins from one slot to the other. It does not rebuild the distribution, move the domain, or touch the certificate. Promotion stays under IaC control through active_deployment_id, not CloudFront’s native promotion API (see the pitfalls).

flowchart TD
    User["Browser"] -->|HTTPS| CF["CloudFront (primary)<br/>active_deployment_id = blue"]

    CF -->|"default behavior"| S3B["Private S3 SPA bucket (blue)"]
    CF -->|"/api/*"| APIB["API Gateway (blue)"]
    APIB --> LB["Lambda (blue)"]

    CFS["CloudFront (staging)"] -.->|"aws-cf-cd-staging: true"| S3G["Private S3 SPA bucket (green)"]
    CFS -.-> APIG["API Gateway (green)"]
    APIG --> LG["Lambda (green)"]

    CDP["Continuous-deployment policy"] -.->|links| CF
    CDP -.-> CFS

    classDef active fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    classDef inactive fill:#f1f8e9,stroke:#558b2f,stroke-dasharray:5 5
    class S3B,APIB,LB active
    class S3G,APIG,LG,CFS inactive

In the diagram, blue is active (solid, serving production through the primary distribution) and green is the inactive slot the staging distribution serves for header-tagged validation. Flip active_deployment_id to green and the treatment swaps.

One stack definition, stamped twice

Blue and green run the same infrastructure code. With Terragrunt explicit stacks, the top-level stack stamps out the same frontend and service stacks twice, changing only the deployment color:

stack "frontend-blue" {
  source = "${values.catalog_root}//stacks/frontend"
  path   = "frontend-blue"
  values = {
    context = merge(local.context, { deployment_id = "blue" })
  }
}

stack "frontend-green" {
  source = "${values.catalog_root}//stacks/frontend"
  path   = "frontend-green"
  values = {
    context = merge(local.context, { deployment_id = "green" })
  }
}

(Trimmed for clarity. The service stack is stamped the same way.)

That single deployment_id difference flows all the way down. It tints the page, sets the Lambda’s deployment color, and appends blue or green to every resource name. You maintain one copy of the code and get two identical, independently addressable copies of the app.

The control surface

Everything an operator touches lives in three values in the live config:

deploy_config = {
  active_deployment_id           = "blue"   # which color serves production
  enable_continuous_deployment   = false    # provision the staging distribution + policy
  activate_continuous_deployment = false    # route header-tagged traffic to staging
}

To switch colors, you change active_deployment_id to "green" and re-apply. To roll back, you set it to "blue" and apply again. Both colors stay deployed the whole time, so a rollback is the same fast operation as the switch.

Ship a UI-only change

A color is a complete {UI bucket + API} slot, so a switch always moves a matching pair. You can still ship a UI-only change without redeploying the API, because two knobs are independent: active_deployment_id picks the live slot, and a per-color versions block pins each color’s frontend and service versions separately. To release new UI on the same API, pin the idle color’s service version to the active color’s current API ref and bump only its frontend version:

versions = {
  frontend_green_version = "?ref=v2-ui"  # new UI
  service_green_version  = "?ref=v1"     # pin to blue's current API ref -> identical API code
}

The slot you validate and promote is always a compatible unit of UI and service.

Resolving which color is live

The shared edge stack reads the active color and picks the primary and staging origins from it:

locals {
  active = try(values.active_deployment_id, "blue")

  s3_primary  = local.active == "blue" ? values.frontend_blue_website_path : values.frontend_green_website_path
  s3_staging  = local.active == "blue" ? values.frontend_green_website_path : values.frontend_blue_website_path

  api_primary = local.active == "blue" ? values.service_blue_api_path : values.service_green_api_path
  api_staging = local.active == "blue" ? values.service_green_api_path : values.service_blue_api_path
}

The primary distribution depends on s3_primary and api_primary. The staging distribution depends on the other color. Flip active_deployment_id, and the next apply re-points both.

Validate on the real domain with continuous deployment

CloudFront continuous deployment adds the staging distribution that serves the inactive color, plus a policy that routes a slice of traffic to it. CloudFront supports two traffic configurations: weight-based (a percentage of viewers, with session stickiness) and header-based (only requests carrying a specific header). The demo uses header-based routing, which is ideal for scripted smoke tests:

traffic_config {
  type = "SingleHeader"
  single_header_config {
    header = "aws-cf-cd-staging"
    value  = "true"
  }
}

Once it is enabled and linked, you test the inactive color through the production domain by sending the header. Everyone else stays on the active color:

curl -H "aws-cf-cd-staging: true" https://<primary-domain>/api/hello   # the inactive color
curl                              https://<primary-domain>/api/hello   # the active color

Gate the promotion on signals, not on a single green smoke test. Before you cut over, watch the staging traffic for at least:

  • CloudFront 4xx/5xx rates
  • API Gateway 4xx/5xx rates
  • Lambda errors, duration, and throttles
  • your smoke-test results sent through the staging header

If those hold steady, you promote by flipping active_deployment_id, then set both continuous-deployment flags back to false.

Operating it

The workflow runs through a small Makefile over Terragrunt:

make apply ENVIRONMENT=demo STACK=app    # deploy / switch / roll back

The demo’s stated prerequisites are OpenTofu 1.12 or newer and Terragrunt 1.0+ with stack support. It does not pin a specific AWS provider version.

Common pitfalls

These are the mistakes that turn a clean blue/green setup into a fragile one. Each has a direct fix.

1. Giving each color its own public front door. If each color gets its own public distribution and domain, every cutover means re-pointing DNS and juggling certificates. Keep one stable public entry point (the primary distribution) and swap its origins between color slots; the staging distribution is for validation only. The production domain and certificate never move.

2. Promoting through CloudFront’s native API. AWS offers a native promotion call, UpdateDistributionWithStagingConfig, that copies the staging config onto the primary. OpenTofu and Terraform have no resource for it, so promoting that way drifts your state and the next apply tries to revert your release. The demo avoids it: promotion is flipping active_deployment_id and re-applying. Continuous deployment here is for pre-promotion testing only.

3. Leaving HTTP/3 enabled. CloudFront continuous deployment is incompatible with HTTP/3. A distribution paired with a continuous-deployment policy must use HTTP/2, so the module sets http_version = "http2" deliberately and comments why. If you need HTTP/3, you cannot use native CloudFront continuous deployment on that distribution.

4. Assuming an origin swap clears the cache. Re-pointing the primary distribution’s origins does not automatically clear CloudFront’s cached objects. A same-path object such as /index.html can stay stale after a color switch and keep referencing the old color’s assets. To handle this:

  • Use short cache TTLs for HTML entry points.
  • Fingerprint JS, CSS, and other static assets so their URLs change per build.
  • Invalidate the entry-point paths during promotion if you need the switch to take effect immediately.

5. Using the wrong staging header. Header-based routing only works if the header name carries the aws-cf-cd- prefix (the demo uses aws-cf-cd-staging). In production, treat the header name and value like a shared secret so outsiders cannot route themselves onto staging, but know they stay visible to anyone with console access. Also check the quotas and considerations: a primary pairs with one staging distribution in the same account, weight-based splits are capped, and some distribution features are incompatible with a CD policy.

6. Ignoring stateful dependencies. Blue/green is straightforward for stateless paths like the SPA, API, and Lambda here. It gets harder the moment colors share state. Database migrations, queues, shared S3 data, and external integrations all need backward-compatible changes, because both colors may touch the same data during a switch. Rollback is only safe if the previous color can still read and write that shared data correctly. Design schema and message changes to work for both colors at once.

7. Non-deterministic build artifacts. The demo packages the Lambda with a plain zip, which is not byte-stable. File ordering and timestamps change the archive, which changes source_code_hash, which can trigger a no-op Lambda redeploy on every apply. Use deterministic packaging so the hash stays stable when the code has not changed.

8. Carrying demo shortcuts into production. A few conveniences are unsafe at scale: -lock=false (it removes concurrency protection), force_destroy on S3 buckets, and an open API Gateway with no authorizer, throttling, or WAF. Also, while an enabled continuous-deployment policy is attached, CloudFront restricts edits to the primary distribution. That is why the apply is two-flag and two-step, and why you set both flags back to false after promoting.

A quick readiness check

Before you call a blue/green setup production-ready, run through these five questions:

  1. One stable public entry point? Switching colors re-points origins under a single primary distribution, with the domain and certificate fixed.
  2. Does promotion stay in IaC state? Cutover happens through your tool’s state, not an out-of-band API or console click.
  3. Is promotion gated on signals? You check CloudFront, API Gateway, and Lambda error/latency metrics plus smoke tests before cutting over.
  4. Is cache invalidation handled on an origin swap? Short-lived HTML, fingerprinted assets, and entry-point invalidation keep users off the old color.
  5. Is rollback safe and tested? You have exercised the way back, and shared state stays backward-compatible across both colors.

The full checklist goes deeper on observability, recovery testing, and access controls.

Wrapping up

Full-stack blue/green on AWS comes down to a few durable ideas. Keep one stable public entry point and treat blue and green as origin slots behind it. Define the infrastructure once and stamp it out per color. Drive switches and rollbacks from a single value, validate the next release on the real domain, gate the cutover on real signals, and keep that cutover inside your IaC state.

The pattern generalizes well beyond this demo. The remaining work is mostly at the edges: custom domains and ACM certificates, deterministic build pipelines, and the observability and automated rollback that watch staging traffic and react on regression. Invest there once the core wiring is solid.

AWS reference: CloudFront continuous deployment.

Free checklist

Get the complete Blue/Green Readiness Checklist

A deeper checklist covering observability, recovery testing, and access controls. Enter your email and confirm via the link we send — then your checklist arrives, plus the occasional note when I publish something new.

Next step

Have a production AWS workload that needs a second opinion?

The Architecture & Delivery Risk Review covers the same delivery risk surface as this article, across your actual repo, pipeline, and infrastructure. Fixed scope, fixed fee, independent findings.