IaC AWS

Terraform Module: AWS CodeDeploy — Blue/Green and rolling deployments as code

Quick take — A reusable Terraform module for AWS CodeDeploy that provisions a CodeDeploy application and a fully wired deployment group with blue/green, auto-rollback, alarms, and deployment-config inputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "codedeploy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codedeploy?ref=v1.0.0"

  name                  = "..."  # CodeDeploy application name and service-role prefix.
  deployment_group_name = "..."  # Deployment group name.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS CodeDeploy is the managed deployment service that takes a new application revision and rolls it out across a fleet — EC2/On-Premises instances, ECS services, or Lambda aliases — while controlling the pace and the safety of that rollout. It is the piece that turns “I built a new artifact” into “the new artifact is serving traffic, and if it misbehaves we automatically go back.”

The two primitives you almost always create together are aws_codedeploy_app (the logical application, scoped to a compute platform) and aws_codedeploy_deployment_group (the how: which targets, which deployment configuration, which rollback rules, which CloudWatch alarms can abort a deploy, and the blue/green or in-place strategy). Hand-writing these is deceptively fiddly: the blue_green_deployment_config, load_balancer_info, auto_rollback_configuration, and alarm_configuration blocks are conditional and easy to get wrong, and the service role needs the correct trust and managed policy per platform.

This module wraps that surface so every team ships with the same guardrails — auto-rollback on failure, alarm-driven aborts, and a sane deployment config — instead of each repo reinventing a brittle deployment group.

When to use it

Module structure

terraform-module-aws-codedeploy/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # CodeDeploy ties the correct AWS managed policy to the compute platform.
  managed_policy_arn = {
    Server = "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole"
    ECS    = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS"
    Lambda = "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambdaLimited"
  }

  # Only attach EC2 tag filters when we are actually deploying to Server targets.
  use_ec2_tag_filters = var.compute_platform == "Server" && length(var.ec2_tag_filters) > 0
}

# ----------------------------------------------------------------------------
# Service role for CodeDeploy
# ----------------------------------------------------------------------------
data "aws_iam_policy_document" "assume" {
  count = var.create_service_role ? 1 : 0

  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["codedeploy.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "this" {
  count = var.create_service_role ? 1 : 0

  name               = "${var.name}-codedeploy-role"
  assume_role_policy = data.aws_iam_policy_document.assume[0].json
  tags               = var.tags
}

resource "aws_iam_role_policy_attachment" "this" {
  count = var.create_service_role ? 1 : 0

  role       = aws_iam_role.this[0].name
  policy_arn = local.managed_policy_arn[var.compute_platform]
}

# ----------------------------------------------------------------------------
# CodeDeploy application
# ----------------------------------------------------------------------------
resource "aws_codedeploy_app" "this" {
  name             = var.name
  compute_platform = var.compute_platform
  tags             = var.tags
}

# ----------------------------------------------------------------------------
# Deployment group
# ----------------------------------------------------------------------------
resource "aws_codedeploy_deployment_group" "this" {
  app_name               = aws_codedeploy_app.this.name
  deployment_group_name  = var.deployment_group_name
  service_role_arn       = var.create_service_role ? aws_iam_role.this[0].arn : var.service_role_arn
  deployment_config_name = var.deployment_config_name

  # In-place vs blue/green.
  deployment_style {
    deployment_type   = var.deployment_type
    deployment_option = var.deployment_option
  }

  # Auto Scaling integration for Server (EC2) deployments.
  autoscaling_groups = var.compute_platform == "Server" ? var.autoscaling_groups : null

  # EC2 tag-based target selection (Server only).
  dynamic "ec2_tag_filter" {
    for_each = local.use_ec2_tag_filters ? var.ec2_tag_filters : []
    content {
      key   = ec2_tag_filter.value.key
      type  = ec2_tag_filter.value.type
      value = ec2_tag_filter.value.value
    }
  }

  # ECS service target (ECS platform only).
  dynamic "ecs_service" {
    for_each = var.compute_platform == "ECS" && var.ecs_service != null ? [var.ecs_service] : []
    content {
      cluster_name = ecs_service.value.cluster_name
      service_name = ecs_service.value.service_name
    }
  }

  # Load balancer wiring for blue/green or in-place-with-LB rollouts.
  dynamic "load_balancer_info" {
    for_each = var.target_group_name != null || length(var.target_group_pair_info) > 0 ? [1] : []
    content {
      # Classic single target group (in-place behind an ELB).
      dynamic "target_group_info" {
        for_each = var.target_group_name != null ? [var.target_group_name] : []
        content {
          name = target_group_info.value
        }
      }

      # Blue/green target group pair behind an ALB/NLB listener.
      dynamic "target_group_pair_info" {
        for_each = var.target_group_pair_info
        content {
          target_group {
            name = target_group_pair_info.value.blue_target_group
          }
          target_group {
            name = target_group_pair_info.value.green_target_group
          }
          prod_traffic_route {
            listener_arns = target_group_pair_info.value.prod_listener_arns
          }
          dynamic "test_traffic_route" {
            for_each = length(target_group_pair_info.value.test_listener_arns) > 0 ? [1] : []
            content {
              listener_arns = target_group_pair_info.value.test_listener_arns
            }
          }
        }
      }
    }
  }

  # Blue/green lifecycle: how the green fleet is provisioned and how the blue
  # fleet is retired.
  dynamic "blue_green_deployment_config" {
    for_each = var.deployment_type == "BLUE_GREEN" ? [1] : []
    content {
      deployment_ready_option {
        action_on_timeout    = var.bg_action_on_timeout
        wait_time_in_minutes = var.bg_action_on_timeout == "STOP_DEPLOYMENT" ? var.bg_wait_time_in_minutes : null
      }

      # Server-only: where the green instances come from.
      dynamic "green_fleet_provisioning_option" {
        for_each = var.compute_platform == "Server" ? [1] : []
        content {
          action = var.bg_green_fleet_provisioning_action
        }
      }

      terminate_blue_instances_on_deployment_success {
        action                           = var.bg_terminate_blue_action
        termination_wait_time_in_minutes = var.bg_terminate_blue_action == "TERMINATE" ? var.bg_terminate_blue_wait_minutes : null
      }
    }
  }

  # Abort + roll back on a failed deployment or a tripped alarm.
  auto_rollback_configuration {
    enabled = var.auto_rollback_enabled
    events  = var.auto_rollback_events
  }

  dynamic "alarm_configuration" {
    for_each = length(var.alarms) > 0 ? [1] : []
    content {
      enabled                   = true
      alarms                    = var.alarms
      ignore_poll_alarm_failure = var.ignore_poll_alarm_failure
    }
  }

  tags = var.tags
}

variables.tf

variable "name" {
  description = "Name of the CodeDeploy application and prefix for the service role."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9._+=,@-]{1,100}$", var.name))
    error_message = "name must be 1-100 chars of letters, numbers, or . _ + = , @ -."
  }
}

variable "compute_platform" {
  description = "Compute platform for the application: Server (EC2/On-Prem), ECS, or Lambda."
  type        = string
  default     = "Server"

  validation {
    condition     = contains(["Server", "ECS", "Lambda"], var.compute_platform)
    error_message = "compute_platform must be one of: Server, ECS, Lambda."
  }
}

variable "deployment_group_name" {
  description = "Name of the deployment group."
  type        = string
}

variable "deployment_config_name" {
  description = "Deployment configuration controlling rollout pace (canary/linear/all-at-once)."
  type        = string
  default     = "CodeDeployDefault.OneAtATime"
}

variable "deployment_type" {
  description = "Deployment style: IN_PLACE or BLUE_GREEN."
  type        = string
  default     = "IN_PLACE"

  validation {
    condition     = contains(["IN_PLACE", "BLUE_GREEN"], var.deployment_type)
    error_message = "deployment_type must be IN_PLACE or BLUE_GREEN."
  }
}

variable "deployment_option" {
  description = "WITH_TRAFFIC_CONTROL to route via a load balancer, otherwise WITHOUT_TRAFFIC_CONTROL."
  type        = string
  default     = "WITHOUT_TRAFFIC_CONTROL"

  validation {
    condition     = contains(["WITH_TRAFFIC_CONTROL", "WITHOUT_TRAFFIC_CONTROL"], var.deployment_option)
    error_message = "deployment_option must be WITH_TRAFFIC_CONTROL or WITHOUT_TRAFFIC_CONTROL."
  }
}

# --- Service role ---------------------------------------------------------
variable "create_service_role" {
  description = "Create the CodeDeploy service role and attach the platform-appropriate managed policy."
  type        = bool
  default     = true
}

variable "service_role_arn" {
  description = "Existing service role ARN to use when create_service_role is false."
  type        = string
  default     = null
}

# --- Targets --------------------------------------------------------------
variable "autoscaling_groups" {
  description = "Auto Scaling group names to deploy to (Server platform)."
  type        = list(string)
  default     = []
}

variable "ec2_tag_filters" {
  description = "EC2 tag filters selecting target instances (Server platform)."
  type = list(object({
    key   = string
    type  = string # KEY_ONLY | VALUE_ONLY | KEY_AND_VALUE
    value = string
  }))
  default = []
}

variable "ecs_service" {
  description = "ECS cluster/service to deploy to (ECS platform)."
  type = object({
    cluster_name = string
    service_name = string
  })
  default = null
}

# --- Load balancer --------------------------------------------------------
variable "target_group_name" {
  description = "Single target group name for in-place deployments behind an ELB."
  type        = string
  default     = null
}

variable "target_group_pair_info" {
  description = "Blue/green target group pairs with prod (and optional test) listener ARNs."
  type = list(object({
    blue_target_group  = string
    green_target_group = string
    prod_listener_arns = list(string)
    test_listener_arns = optional(list(string), [])
  }))
  default = []
}

# --- Blue/green tuning ----------------------------------------------------
variable "bg_action_on_timeout" {
  description = "On a blue/green deployment-ready timeout: CONTINUE_DEPLOYMENT or STOP_DEPLOYMENT."
  type        = string
  default     = "CONTINUE_DEPLOYMENT"

  validation {
    condition     = contains(["CONTINUE_DEPLOYMENT", "STOP_DEPLOYMENT"], var.bg_action_on_timeout)
    error_message = "bg_action_on_timeout must be CONTINUE_DEPLOYMENT or STOP_DEPLOYMENT."
  }
}

variable "bg_wait_time_in_minutes" {
  description = "Minutes to wait for manual reroute when bg_action_on_timeout is STOP_DEPLOYMENT."
  type        = number
  default     = 60
}

variable "bg_green_fleet_provisioning_action" {
  description = "How green instances are provisioned (Server): DISCOVER_EXISTING or COPY_AUTO_SCALING_GROUP."
  type        = string
  default     = "COPY_AUTO_SCALING_GROUP"

  validation {
    condition     = contains(["DISCOVER_EXISTING", "COPY_AUTO_SCALING_GROUP"], var.bg_green_fleet_provisioning_action)
    error_message = "bg_green_fleet_provisioning_action must be DISCOVER_EXISTING or COPY_AUTO_SCALING_GROUP."
  }
}

variable "bg_terminate_blue_action" {
  description = "After success, action for blue fleet: TERMINATE or KEEP_ALIVE."
  type        = string
  default     = "TERMINATE"

  validation {
    condition     = contains(["TERMINATE", "KEEP_ALIVE"], var.bg_terminate_blue_action)
    error_message = "bg_terminate_blue_action must be TERMINATE or KEEP_ALIVE."
  }
}

variable "bg_terminate_blue_wait_minutes" {
  description = "Minutes to keep the blue fleet alive before termination."
  type        = number
  default     = 5
}

# --- Rollback + alarms ----------------------------------------------------
variable "auto_rollback_enabled" {
  description = "Enable automatic rollback on the configured events."
  type        = bool
  default     = true
}

variable "auto_rollback_events" {
  description = "Events that trigger an automatic rollback."
  type        = list(string)
  default     = ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"]

  validation {
    condition = alltrue([
      for e in var.auto_rollback_events :
      contains(["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM", "DEPLOYMENT_STOP_ON_REQUEST"], e)
    ])
    error_message = "auto_rollback_events values must be DEPLOYMENT_FAILURE, DEPLOYMENT_STOP_ON_ALARM, or DEPLOYMENT_STOP_ON_REQUEST."
  }
}

variable "alarms" {
  description = "CloudWatch alarm names that, when in ALARM, stop and roll back the deployment."
  type        = list(string)
  default     = []
}

variable "ignore_poll_alarm_failure" {
  description = "Continue the deployment if CodeDeploy cannot read alarm state from CloudWatch."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Tags applied to all created resources."
  type        = map(string)
  default     = {}
}

outputs.tf

output "app_id" {
  description = "CodeDeploy application ID."
  value       = aws_codedeploy_app.this.id
}

output "app_name" {
  description = "CodeDeploy application name."
  value       = aws_codedeploy_app.this.name
}

output "application_id" {
  description = "Internal application unique identifier."
  value       = aws_codedeploy_app.this.application_id
}

output "deployment_group_id" {
  description = "Deployment group ID."
  value       = aws_codedeploy_deployment_group.this.id
}

output "deployment_group_name" {
  description = "Deployment group name."
  value       = aws_codedeploy_deployment_group.this.deployment_group_name
}

output "deployment_config_name" {
  description = "Active deployment configuration name."
  value       = aws_codedeploy_deployment_group.this.deployment_config_name
}

output "service_role_arn" {
  description = "ARN of the CodeDeploy service role in use."
  value       = var.create_service_role ? aws_iam_role.this[0].arn : var.service_role_arn
}

How to use it

This example deploys an ECS service blue/green behind an ALB, aborting and rolling back if the 5xx alarm trips:

module "codedeploy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codedeploy?ref=v1.0.0"

  name                  = "checkout-api"
  compute_platform      = "ECS"
  deployment_group_name = "checkout-api-prod"

  # Canary: 10% of traffic, then everything 5 minutes later.
  deployment_config_name = "CodeDeployDefault.ECSCanary10Percent5Minutes"
  deployment_type        = "BLUE_GREEN"
  deployment_option      = "WITH_TRAFFIC_CONTROL"

  ecs_service = {
    cluster_name = "prod-cluster"
    service_name = "checkout-api"
  }

  target_group_pair_info = [{
    blue_target_group  = "checkout-api-blue"
    green_target_group = "checkout-api-green"
    prod_listener_arns = [aws_lb_listener.prod.arn]
    test_listener_arns = [aws_lb_listener.test.arn]
  }]

  bg_terminate_blue_action       = "TERMINATE"
  bg_terminate_blue_wait_minutes = 10

  alarms = [aws_cloudwatch_metric_alarm.checkout_5xx.arn]

  tags = {
    team        = "payments"
    environment = "prod"
  }
}

# Downstream: hand the deployment group name to a CodePipeline deploy stage.
resource "aws_codepipeline" "checkout" {
  name     = "checkout-api"
  role_arn = aws_iam_role.pipeline.arn

  # ... artifact_store, source and build stages omitted ...

  stage {
    name = "Deploy"
    action {
      name            = "DeployToECS"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "CodeDeployToECS"
      version         = "1"
      input_artifacts = ["build_output"]

      configuration = {
        ApplicationName     = module.codedeploy.app_name
        DeploymentGroupName = module.codedeploy.deployment_group_name
      }
    }
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/codedeploy/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codedeploy?ref=v1.0.0"
}

inputs = {
  name = "..."
  deployment_group_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/codedeploy && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes CodeDeploy application name and service-role prefix.
compute_platform string “Server” No Server, ECS, or Lambda.
deployment_group_name string Yes Deployment group name.
deployment_config_name string “CodeDeployDefault.OneAtATime” No Rollout pace (canary/linear/all-at-once).
deployment_type string “IN_PLACE” No IN_PLACE or BLUE_GREEN.
deployment_option string “WITHOUT_TRAFFIC_CONTROL” No WITH_TRAFFIC_CONTROL to route via a load balancer.
create_service_role bool true No Create the service role and attach the platform managed policy.
service_role_arn string null No Existing role ARN when create_service_role is false.
autoscaling_groups list(string) [] No Auto Scaling groups to deploy to (Server).
ec2_tag_filters list(object) [] No EC2 tag filters for target selection (Server).
ecs_service object null No ECS cluster/service target (ECS).
target_group_name string null No Single target group for in-place behind an ELB.
target_group_pair_info list(object) [] No Blue/green target group pairs + listener ARNs.
bg_action_on_timeout string “CONTINUE_DEPLOYMENT” No CONTINUE_DEPLOYMENT or STOP_DEPLOYMENT on ready timeout.
bg_wait_time_in_minutes number 60 No Manual reroute wait when STOP_DEPLOYMENT.
bg_green_fleet_provisioning_action string “COPY_AUTO_SCALING_GROUP” No DISCOVER_EXISTING or COPY_AUTO_SCALING_GROUP (Server).
bg_terminate_blue_action string “TERMINATE” No TERMINATE or KEEP_ALIVE for the blue fleet after success.
bg_terminate_blue_wait_minutes number 5 No Minutes before terminating the blue fleet.
auto_rollback_enabled bool true No Enable automatic rollback.
auto_rollback_events list(string) [“DEPLOYMENT_FAILURE”,“DEPLOYMENT_STOP_ON_ALARM”] No Events that trigger rollback.
alarms list(string) [] No CloudWatch alarms that stop and roll back the deploy.
ignore_poll_alarm_failure bool false No Proceed if alarm state cannot be read.
tags map(string) {} No Tags for all resources.

Outputs

Name Description
app_id CodeDeploy application ID.
app_name CodeDeploy application name (feed to CodePipeline).
application_id Internal application unique identifier.
deployment_group_id Deployment group ID.
deployment_group_name Deployment group name (feed to CodePipeline).
deployment_config_name Active deployment configuration name.
service_role_arn ARN of the CodeDeploy service role in use.

Enterprise scenario

A payments platform runs 40+ ECS services and mandates that no production release can shift 100% of traffic at once. Each service’s Terraform stack calls this module with deployment_type = "BLUE_GREEN", a CodeDeployDefault.ECSCanary10Percent5Minutes config, and the team’s per-service 5xx and p99-latency CloudWatch alarms wired into alarms. When a bad build pushes 5xx above the SLO during the 10% canary window, CodeDeploy stops the deployment and reroutes to the blue task set automatically — the on-call engineer wakes up to a rolled-back deploy and a clean alarm, not a customer-facing outage.

Best practices

TerraformAWSCodeDeployModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading