CI/CD Pipeline: CodePipeline + CodeBuild + CodeDeploy to ECS
Scenario
Your team currently deploys the containerised API by running docker build and terraform apply manually from a developer's laptop. This is slow, error-prone, and leaves no audit trail. You need a fully automated CI/CD pipeline that: pulls source from GitHub, builds and pushes a Docker image to ECR, and deploys the new image to ECS using a blue/green deployment strategy.
- Create an S3 bucket as the CodePipeline artifact store (versioning enabled, server-side encryption).
- Create a CodeBuild project with an IAM role that can pull/push to ECR, read the source artifact, and write logs to CloudWatch.
- Create a CodeDeploy application and deployment group targeting ECS (blue/green). Configure it to route traffic through an ALB listener.
- Create a CodePipeline with three stages: Source (GitHub via CodeStar connection or S3), Build (CodeBuild), Deploy (CodeDeploy ECS blue/green).
- Create a pipeline IAM role with permissions to use all three services and pass roles to CodeBuild and CodeDeploy.
- Output the pipeline ARN and the CodeBuild project name.
Additional Context
Blue/Green ECS deployments via CodeDeploy: CodeDeploy creates a new (green) ECS task set, routes a percentage of traffic to it via the ALB target group, monitors for errors, and terminates the old (blue) task set after a stabilisation period. If alarms fire during deployment, CodeDeploy automatically rolls back to the blue set.
The CodeStar connection (aws_codestarconnections_connection) must be manually authorised once in the AWS console after Terraform creates it — it enters PENDING state until a human approves the GitHub OAuth flow. Terraform cannot perform this authorisation step for you.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "pipeline_name" { type = string; default = "web-api-pipeline" }
variable "ecr_repo_url" { type = string; description = "ECR repository URL (no tag)." }
variable "ecs_cluster" { type = string; description = "ECS cluster name." }
variable "ecs_service" { type = string; description = "ECS service name." }
variable "alb_listener_arn" { type = string; description = "ALB listener ARN for blue/green traffic routing." }
variable "tg_blue_arn" { type = string; description = "Blue target group ARN." }
variable "tg_green_arn" { type = string; description = "Green target group ARN." }
variable "github_owner" { type = string; description = "GitHub organization or username." }
variable "github_repo" { type = string; description = "GitHub repository name." }
variable "github_branch" { type = string; default = "main" }
variable "environment" { type = string; default = "prod" }main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
data "aws_caller_identity" "current" {}
# S3 bucket for pipeline artifacts (source zip, build output).
resource "aws_s3_bucket" "artifacts" {
bucket = "${var.pipeline_name}-artifacts-${data.aws_caller_identity.current.account_id}"
tags = local.tags
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }
}
# CodeStar connection for GitHub — MUST be manually authorised in the AWS Console after apply.
resource "aws_codestarconnections_connection" "github" {
name = "${var.pipeline_name}-github"
provider_type = "GitHub"
}
# CodeBuild IAM role.
data "aws_iam_policy_document" "codebuild_assume" {
statement { effect = "Allow"; actions = ["sts:AssumeRole"]
principals { type = "Service"; identifiers = ["codebuild.amazonaws.com"] } }
}
resource "aws_iam_role" "codebuild" {
name = "${var.pipeline_name}-codebuild-role"
assume_role_policy = data.aws_iam_policy_document.codebuild_assume.json
}
resource "aws_iam_role_policy" "codebuild" {
name = "codebuild-permissions"
role = aws_iam_role.codebuild.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow"; Action = ["ecr:GetAuthorizationToken"]; Resource = "*" },
{ Effect = "Allow"; Action = ["ecr:BatchCheckLayerAvailability","ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage","ecr:InitiateLayerUpload","ecr:UploadLayerPart",
"ecr:CompleteLayerUpload","ecr:PutImage"]; Resource = "*" },
{ Effect = "Allow"; Action = ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"]; Resource = "*" },
{ Effect = "Allow"; Action = ["s3:GetObject","s3:PutObject","s3:GetObjectVersion"]; Resource = "${aws_s3_bucket.artifacts.arn}/*" }
]
})
}
# CodeBuild project — builds Docker image and pushes to ECR.
resource "aws_codebuild_project" "build" {
name = "${var.pipeline_name}-build"
service_role = aws_iam_role.codebuild.arn
artifacts { type = "CODEPIPELINE" }
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/standard:7.0"
type = "LINUX_CONTAINER"
privileged_mode = true # required for docker daemon access
environment_variable { name = "ECR_REPO_URL"; value = var.ecr_repo_url }
environment_variable { name = "AWS_DEFAULT_REGION"; value = var.aws_region }
environment_variable { name = "AWS_ACCOUNT_ID"; value = data.aws_caller_identity.current.account_id }
}
source {
type = "CODEPIPELINE"
buildspec = <<-BUILDSPEC
version: 0.2
phases:
pre_build:
commands:
- aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_REPO_URL
- IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
build:
commands:
- docker build -t $ECR_REPO_URL:$IMAGE_TAG .
- docker push $ECR_REPO_URL:$IMAGE_TAG
post_build:
commands:
- printf '[{"name":"web","imageUri":"%s:%s"}]' $ECR_REPO_URL $IMAGE_TAG > imagedefinitions.json
artifacts:
files: imagedefinitions.json
BUILDSPEC
}
tags = local.tags
}
# CodeDeploy — ECS blue/green deployment.
resource "aws_iam_role" "codedeploy" {
name = "${var.pipeline_name}-codedeploy-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "codedeploy.amazonaws.com" }; Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy_attachment" "codedeploy" {
role = aws_iam_role.codedeploy.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS"
}
resource "aws_codedeploy_app" "ecs" {
name = "${var.pipeline_name}-ecs-app"
compute_platform = "ECS"
}
resource "aws_codedeploy_deployment_group" "ecs" {
app_name = aws_codedeploy_app.ecs.name
deployment_group_name = "${var.pipeline_name}-dg"
service_role_arn = aws_iam_role.codedeploy.arn
deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
ecs_service { cluster_name = var.ecs_cluster; service_name = var.ecs_service }
deployment_style { deployment_option = "WITH_TRAFFIC_CONTROL"; deployment_type = "BLUE_GREEN" }
blue_green_deployment_config {
terminate_blue_instances_on_deployment_success { action = "TERMINATE"; termination_wait_time_in_minutes = 5 }
deployment_ready_option { action_on_timeout = "CONTINUE_DEPLOYMENT" }
}
load_balancer_info {
target_group_pair_info {
prod_traffic_route { listener_arns = [var.alb_listener_arn] }
target_group { name = split("/", var.tg_blue_arn)[1] }
target_group { name = split("/", var.tg_green_arn)[1] }
}
}
}
# CodePipeline IAM role.
resource "aws_iam_role" "pipeline" {
name = "${var.pipeline_name}-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow"; Principal = { Service = "codepipeline.amazonaws.com" }; Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy" "pipeline" {
name = "pipeline-permissions"
role = aws_iam_role.pipeline.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow"; Action = ["s3:GetObject","s3:PutObject","s3:GetBucketVersioning"]; Resource = ["${aws_s3_bucket.artifacts.arn}","${aws_s3_bucket.artifacts.arn}/*"] },
{ Effect = "Allow"; Action = ["codebuild:StartBuild","codebuild:BatchGetBuilds"]; Resource = aws_codebuild_project.build.arn },
{ Effect = "Allow"; Action = ["codedeploy:*"]; Resource = "*" },
{ Effect = "Allow"; Action = ["codestar-connections:UseConnection"]; Resource = aws_codestarconnections_connection.github.arn },
{ Effect = "Allow"; Action = ["iam:PassRole"]; Resource = [aws_iam_role.codebuild.arn, aws_iam_role.codedeploy.arn] }
]
})
}
# The CodePipeline.
resource "aws_codepipeline" "main" {
name = var.pipeline_name
role_arn = aws_iam_role.pipeline.arn
tags = local.tags
artifact_store { location = aws_s3_bucket.artifacts.bucket; type = "S3" }
stage {
name = "Source"
action {
name = "GitHub_Source"
category = "Source"
owner = "AWS"
provider = "CodeStarSourceConnection"
version = "1"
output_artifacts = ["source_output"]
configuration = {
ConnectionArn = aws_codestarconnections_connection.github.arn
FullRepositoryId = "${var.github_owner}/${var.github_repo}"
BranchName = var.github_branch
}
}
}
stage {
name = "Build"
action {
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["source_output"]
output_artifacts = ["build_output"]
configuration = { ProjectName = aws_codebuild_project.build.name }
}
}
stage {
name = "Deploy"
action {
name = "Deploy_ECS"
category = "Deploy"
owner = "AWS"
provider = "CodeDeployToECS"
version = "1"
input_artifacts = ["build_output"]
configuration = {
ApplicationName = aws_codedeploy_app.ecs.name
DeploymentGroupName = aws_codedeploy_deployment_group.ecs.deployment_group_name
TaskDefinitionTemplateArtifact = "build_output"
ImageContainerName = "web"
Image1ArtifactName = "build_output"
Image1ContainerName = "web"
}
}
}
}outputs.tf
output "pipeline_arn" { value = aws_codepipeline.main.arn }
output "codebuild_project" { value = aws_codebuild_project.build.name }
output "github_connection_arn" { value = aws_codestarconnections_connection.github.arn }
output "connection_status" { value = aws_codestarconnections_connection.github.connection_status }Workflow Commands
terraform init
terraform apply -var="github_owner=myorg" -var="github_repo=web-api" \
-var="ecr_repo_url=123456789012.dkr.ecr.us-east-1.amazonaws.com/web-api" \
-var="ecs_cluster=web-api-cluster" -var="ecs_service=web-api-cluster-web-svc" \
-var="alb_listener_arn=arn:aws:elasticloadbalancing:..." \
-var="tg_blue_arn=arn:aws:elasticloadbalancing:..." \
-var="tg_green_arn=arn:aws:elasticloadbalancing:..."
# IMPORTANT: Manually go to AWS Console → CodePipeline → Settings → Connections
# and click "Update pending connection" to authorise the GitHub OAuth flow.
# connection_status will change from PENDING to AVAILABLE.
# Check connection status
terraform output connection_status # must be "AVAILABLE" before pipeline can run
# Trigger a pipeline run (or push a commit to GitHub)
aws codepipeline start-pipeline-execution --name $(terraform output -raw pipeline_arn | awk -F':' '{print $NF}')
terraform destroy -var="github_owner=..." ...Apply complete! Resources: 11 added, 0 changed, 0 destroyed. Outputs: codebuild_project = "web-api-pipeline-build" connection_status = "PENDING" (must be manually activated in console) github_connection_arn = "arn:aws:codestar-connections:us-east-1:123456789012:connection/xxxx" pipeline_arn = "arn:aws:codepipeline:us-east-1:123456789012:web-api-pipeline"

