AWS CodePipeline: Building a Production CI/CD Pipeline from Scratch

Manual deployments are a solved problem. Every team that still deploys by SSH-ing into a server or running scripts locally is carrying risk that compounds over time. Deployments become infrequent because they are stressful. Infrequent deployments mean large batches of changes per release. Large batches mean harder rollbacks and more things that can go wrong simultaneously.

AWS CodePipeline combined with CodeBuild gives you a fully managed CI/CD pipeline that runs on every commit, enforces quality gates, and deploys consistently to every environment without manual intervention. In this article I will walk you through building a real pipeline that builds a Docker image, pushes it to ECR, and deploys it to ECS Fargate across multiple environments using Terraform.

Pipeline Architecture

The pipeline we are building has four stages. Source pulls the latest code from CodeCommit or GitHub on every commit to the main branch. Build runs tests, builds the Docker image, and pushes it to ECR. Deploy-Staging deploys the new image to the staging ECS service and runs smoke tests. Deploy-Production requires a manual approval, then deploys to the production ECS service.

This is a pattern that works for most production applications. The manual approval gate before production is a deliberate choice. Automated deployment to staging is fast and gives you confidence. The production gate gives humans a checkpoint to review staging results before they affect real users.

Prerequisites: ECR Repository and S3 Artifact Bucket

resource "aws_ecr_repository" "app" {
  name                 = "app"
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  encryption_configuration {
    encryption_type = "KMS"
    kms_key         = aws_kms_key.ecr.arn
  }
}

resource "aws_ecr_lifecycle_policy" "app" {
  repository = aws_ecr_repository.app.name

  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Keep last 10 production images"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["prod-"]
          countType     = "imageCountMoreThan"
          countNumber   = 10
        }
        action = { type = "expire" }
      },
      {
        rulePriority = 2
        description  = "Expire untagged images after 7 days"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 7
        }
        action = { type = "expire" }
      }
    ]
  })
}

resource "aws_s3_bucket" "pipeline_artifacts" {
  bucket = "codepipeline-artifacts-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket_versioning" "pipeline_artifacts" {
  bucket = aws_s3_bucket.pipeline_artifacts.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "pipeline_artifacts" {
  bucket = aws_s3_bucket.pipeline_artifacts.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.pipeline.arn
    }
  }
}

resource "aws_s3_bucket_public_access_block" "pipeline_artifacts" {
  bucket                  = aws_s3_bucket.pipeline_artifacts.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

image_tag_mutability = “IMMUTABLE” prevents overwriting an existing image tag. This is critical for production pipelines. If tags are mutable, a failed build that produces a broken image with the same tag as a known good image can silently replace it. With immutable tags, each image is a permanent artifact identified by its exact content.

CodeBuild Project

resource "aws_codebuild_project" "build" {
  name          = "app-build"
  description   = "Build and push Docker image to ECR"
  build_timeout = 20
  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
    image_pull_credentials_type = "CODEBUILD"

    environment_variable {
      name  = "AWS_DEFAULT_REGION"
      value = var.region
    }
    environment_variable {
      name  = "ECR_REPOSITORY_URI"
      value = aws_ecr_repository.app.repository_url
    }
  }

  vpc_config {
    vpc_id             = var.vpc_id
    subnets            = var.private_subnet_ids
    security_group_ids = [aws_security_group.codebuild.id]
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "/aws/codebuild/app-build"
      stream_name = "build-log"
    }
  }

  cache {
    type  = "LOCAL"
    modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"]
  }
}

privileged_mode = true is required for building Docker images inside CodeBuild. The build container needs elevated privileges to run the Docker daemon. This is safe within CodeBuild’s managed environment.

Local Docker layer caching cuts build times significantly for applications where dependencies change infrequently. The base image layers and package installation steps are cached between builds, and only the layers that changed are rebuilt.

The Buildspec

The buildspec.yml lives in your repository and defines what CodeBuild actually runs:

version: 0.2

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REPOSITORY_URI
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-8)
      - IMAGE_TAG=${COMMIT_HASH}

  build:
    commands:
      - echo Running unit tests
      - pip install -r requirements.txt
      - pytest tests/unit/ --tb=short -q
      - echo Building Docker image
      - docker build -t $ECR_REPOSITORY_URI:$IMAGE_TAG -t $ECR_REPOSITORY_URI:latest .

  post_build:
    commands:
      - echo Pushing Docker image to ECR
      - docker push $ECR_REPOSITORY_URI:$IMAGE_TAG
      - docker push $ECR_REPOSITORY_URI:latest
      - echo Writing image definitions file
      - printf '[{"name":"app","imageUri":"%s"}]' $ECR_REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json

artifacts:
  files:
    - imagedefinitions.json

The imagedefinitions.json file is the handoff artifact between CodeBuild and CodeDeploy for ECS deployments. It tells the ECS deploy action exactly which container name and image URI to update in the task definition. The format must match exactly: the name field must match the container name in your ECS task definition.

The Full Pipeline

resource "aws_codepipeline" "main" {
  name     = "app-pipeline"
  role_arn = aws_iam_role.codepipeline.arn

  artifact_store {
    location = aws_s3_bucket.pipeline_artifacts.bucket
    type     = "S3"

    encryption_key {
      id   = aws_kms_key.pipeline.arn
      type = "KMS"
    }
  }

  stage {
    name = "Source"
    action {
      name             = "Source"
      category         = "Source"
      owner            = "ThirdParty"
      provider         = "GitHub"
      version          = "2"
      output_artifacts = ["source_output"]

      configuration = {
        ConnectionArn        = aws_codestarconnections_connection.github.arn
        FullRepositoryId     = "your-org/your-repo"
        BranchName           = "main"
        OutputArtifactFormat = "CODE_ZIP"
        DetectChanges        = true
      }
    }
  }

  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-Staging"
    action {
      name            = "DeployToStaging"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "ECS"
      version         = "1"
      input_artifacts = ["build_output"]

      configuration = {
        ClusterName = aws_ecs_cluster.staging.name
        ServiceName = aws_ecs_service.app_staging.name
        FileName    = "imagedefinitions.json"
      }
    }
  }

  stage {
    name = "Approve-Production"
    action {
      name     = "ManualApproval"
      category = "Approval"
      owner    = "AWS"
      provider = "Manual"
      version  = "1"

      configuration = {
        NotificationArn = aws_sns_topic.pipeline_approvals.arn
        CustomData      = "Review staging deployment at https://staging.yourapp.com before approving production."
      }
    }
  }

  stage {
    name = "Deploy-Production"
    action {
      name            = "DeployToProduction"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "ECS"
      version         = "1"
      input_artifacts = ["build_output"]

      configuration = {
        ClusterName = aws_ecs_cluster.production.name
        ServiceName = aws_ecs_service.app_production.name
        FileName    = "imagedefinitions.json"
      }
    }
  }
}

The GitHub connection uses AWS CodeStar Connections, which is the modern approach for connecting to GitHub. It uses OAuth at the organization level rather than personal access tokens, which means it survives individual team members leaving and does not need to be rotated manually.

IAM Roles

resource "aws_iam_role" "codepipeline" {
  name = "codepipeline-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action    = "sts:AssumeRole"
      Effect    = "Allow"
      Principal = { Service = "codepipeline.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "codepipeline" {
  name = "codepipeline-policy"
  role = aws_iam_role.codepipeline.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = ["s3:GetObject", "s3:PutObject", "s3:GetBucketVersioning"]
        Resource = [
          aws_s3_bucket.pipeline_artifacts.arn,
          "${aws_s3_bucket.pipeline_artifacts.arn}/*"
        ]
      },
      {
        Effect   = "Allow"
        Action   = ["codebuild:BatchGetBuilds", "codebuild:StartBuild"]
        Resource = aws_codebuild_project.build.arn
      },
      {
        Effect   = "Allow"
        Action   = ["ecs:DescribeServices", "ecs:DescribeTaskDefinition", "ecs:DescribeTasks", "ecs:ListTasks", "ecs:RegisterTaskDefinition", "ecs:UpdateService"]
        Resource = "*"
      },
      {
        Effect   = "Allow"
        Action   = ["iam:PassRole"]
        Resource = [aws_iam_role.ecs_execution.arn, aws_iam_role.ecs_task.arn]
      },
      {
        Effect   = "Allow"
        Action   = ["codestar-connections:UseConnection"]
        Resource = aws_codestarconnections_connection.github.arn
      },
      {
        Effect   = "Allow"
        Action   = ["sns:Publish"]
        Resource = aws_sns_topic.pipeline_approvals.arn
      },
      {
        Effect   = "Allow"
        Action   = ["kms:GenerateDataKey", "kms:Decrypt"]
        Resource = aws_kms_key.pipeline.arn
      }
    ]
  })
}

Pipeline Notifications

Knowing when a pipeline fails without checking the console manually is table stakes for production CI/CD:

resource "aws_codestarnotifications_notification_rule" "pipeline" {
  name        = "app-pipeline-notifications"
  detail_type = "FULL"
  resource    = aws_codepipeline.main.arn

  event_type_ids = [
    "codepipeline-pipeline-pipeline-execution-failed",
    "codepipeline-pipeline-pipeline-execution-succeeded",
    "codepipeline-pipeline-manual-approval-needed"
  ]

  target {
    type    = "SNS"
    address = aws_sns_topic.pipeline_notifications.arn
  }
}

Subscribe your team’s Slack channel or email distribution list to this SNS topic. A failed build should surface immediately where your engineers are working, not sit unnoticed in the AWS console until someone happens to check.

Rollback Strategy

ECS deployments with CodePipeline support rollbacks through ECS deployment circuit breakers. Configure this on your ECS service:

resource "aws_ecs_service" "app_production" {
  name            = "app-production"
  cluster         = aws_ecs_cluster.production.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  deployment_controller {
    type = "ECS"
  }

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = [aws_security_group.app.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 8080
  }
}

With deployment_circuit_breaker enabled and rollback = true, ECS monitors the new task instances during deployment. If they fail to reach a healthy state within the deployment thresholds, ECS automatically rolls back to the previous task definition version. Your pipeline shows the deployment as failed and the previous version keeps serving traffic.

Closing Thoughts

A good CI/CD pipeline is infrastructure, not an afterthought. The pipeline you set up on day one shapes how your team deploys for the lifetime of the product. Invest in it properly.

The non-negotiables are immutable image tags, automated tests before any deployment, a staging environment that mirrors production, and automatic rollback on deployment failure. Everything else is refinement on top of that foundation.

The manual approval gate before production is a team culture decision as much as a technical one. For teams moving fast with high test coverage and a mature staging environment, removing it and going fully automated is reasonable. For most teams, keeping the gate gives engineers a moment to verify staging before customer impact. Either way, make the decision deliberately.

Enjoy the cloud.

Osama


#AWS #CodePipeline #CodeBuild #CICD #DevOps #CloudNative #ECS #Fargate #ECR #Terraform #InfrastructureAsCode #AmazonWebServices #SolutionsArchitect #CloudComputing #BackendEngineering #SoftwareDelivery #Automation #TechBlog #CloudArchitecture #ContinuousDeployment

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.