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
- You run EC2/Auto Scaling or ECS workloads and want canary, linear, or all-at-once rollouts with automatic rollback instead of
aws ecs update-serviceand hope. - You need blue/green behind an ALB/NLB target group so a bad release never takes the old fleet down.
- You want CloudWatch alarms (5xx rate, latency, custom SLO metric) to abort and roll back a deployment automatically.
- You are standardizing many services and want one audited, version-pinned deployment-group definition across all of them.
- Skip it for a single throwaway script or a static site (SWA/S3+CloudFront) where there is no fleet to roll out to.
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 config — live/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 config — live/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
- Always pair
alarmswithauto_rollback_events = ["DEPLOYMENT_STOP_ON_ALARM", ...]. Alarms only abort a deployment if rollback is also listening forDEPLOYMENT_STOP_ON_ALARM; configuring one without the other gives you a false sense of safety. - Prefer canary/linear configs over
AllAtOncein production.CodeDeployDefault.ECSCanary10Percent5Minutes(or a customaws_codedeploy_deployment_config) limits blast radius; reserve all-at-once for dev where rollout speed beats safety. - Let the module create the service role so the platform-correct managed policy (
AWSCodeDeployRoleForECS,AWSCodeDeployRole, etc.) is attached automatically — a mismatched policy is the most common reason a deployment group fails to start a deployment. - Cost: set
bg_terminate_blue_action = "TERMINATE"with a short wait. Leaving blue instances or task sets alive (KEEP_ALIVE) doubles compute spend for the whole window, which adds up fast across dozens of services. - Use a consistent
name/deployment_group_nameconvention like<service>-<env>and applytags(team, environment) so deployments are attributable and cost-allocable across the fleet. - Keep
ignore_poll_alarm_failure = falsein production. If CodeDeploy cannot read alarm state, you want the deployment to halt rather than proceed blind past your safety net.