LAB32_AWS_CI/CD Pipeline: CodePipeline + CodeBuild + CodeDeploy to ECS

CI/CD Pipeline: CodePipeline + CodeBuild + CodeDeploy to ECS

☁ AWS 🔥 Advanced CodePipeline CodeBuild CodeDeploy ECS Blue/Green IAM Roles

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.

Your Objectives
  • 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=..." ...
✓ Expected Output After Apply
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"