AWS CodePipeline and CodeBuild: CI/CD Pipelines Without Leaving AWS

Bits Lovers
Written by Bits Lovers on
AWS CodePipeline and CodeBuild: CI/CD Pipelines Without Leaving AWS

AWS CodePipeline and CodeBuild give you a CI/CD stack that stays entirely within AWS — no Jenkins to maintain, no GitHub Actions runner infrastructure, no CircleCI seat costs. CodeBuild runs your build jobs on managed compute that scales to zero between builds. CodePipeline orchestrates the stages from source to deployment. Both integrate natively with IAM, Secrets Manager, ECR, ECS, and S3, which is why teams building AWS-native applications often prefer them over external CI systems.

The main reason to avoid them: if your source is GitHub, the developer experience is significantly better with GitHub Actions. CodePipeline’s GitHub integration works, but GitHub Actions has better visibility, easier local reproduction of CI steps, and a much larger ecosystem of actions. The right tradeoff is usually GitHub Actions for builds and tests, CodePipeline for the deployment stages that need deep AWS integration and cross-account promotion.

This guide covers building a complete pipeline from source to ECS deployment, CodeBuild buildspec patterns, manual approval gates, and cross-account deployments.

CodeBuild: Build Specification

CodeBuild runs builds in managed Docker containers. You define the build in a buildspec.yml at the root of your repo, or provide the commands inline when creating the project.

# buildspec.yml
version: 0.2

env:
  variables:
    AWS_DEFAULT_REGION: us-east-1
    ECR_REPOSITORY: my-api
  parameter-store:
    DB_HOST: /myapp/production/db-host
  secrets-manager:
    GITHUB_TOKEN: myapp/github-token:token

phases:
  install:
    runtime-versions:
      python: 3.12
    commands:
      - pip install --upgrade pip
      - pip install -r requirements-test.txt

  pre_build:
    commands:
      - echo "Logging in to ECR..."
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
      - export IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:8}
      - export IMAGE_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY:$IMAGE_TAG
      
      # Run tests before building
      - python -m pytest tests/ -v --junitxml=test-results.xml

  build:
    commands:
      - echo "Building Docker image..."
      - docker build -t $IMAGE_URI .
      - docker tag $IMAGE_URI $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY:latest

  post_build:
    commands:
      - echo "Pushing image to ECR..."
      - docker push $IMAGE_URI
      - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY:latest
      
      # Write image definition for ECS CodeDeploy action
      - printf '[{"name":"my-api","imageUri":"%s"}]' $IMAGE_URI > imagedefinitions.json

reports:
  TestResults:
    files:
      - test-results.xml
    file-format: JunitXml

artifacts:
  files:
    - imagedefinitions.json
    - appspec.yaml
    - taskdef.json

cache:
  paths:
    - /root/.cache/pip/**/*

CODEBUILD_RESOLVED_SOURCE_VERSION is the full commit SHA. Truncating to 8 characters gives a readable image tag. Cache pip dependencies under /root/.cache/pip to avoid reinstalling packages on every build.

The imagedefinitions.json artifact is what CodePipeline’s ECS deploy action reads to know which image to deploy. Its format is exactly [{"name": "container-name", "imageUri": "ecr-uri:tag"}].

Creating a CodeBuild Project

# Create CodeBuild service role
cat > /tmp/codebuild-trust.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "codebuild.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}
EOF

CB_ROLE_ARN=$(aws iam create-role \
  --role-name CodeBuildRole \
  --assume-role-policy-document file:///tmp/codebuild-trust.json \
  --query 'Role.Arn' --output text)

# Attach policies (ECR push, Secrets Manager, CloudWatch Logs, S3 for artifacts)
aws iam attach-role-policy --role-name CodeBuildRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
aws iam attach-role-policy --role-name CodeBuildRole \
  --policy-arn arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
aws iam attach-role-policy --role-name CodeBuildRole \
  --policy-arn arn:aws:iam::aws:policy/SecretsManagerReadWrite

# Create the CodeBuild project
aws codebuild create-project \
  --name my-api-build \
  --source '{
    "type": "GITHUB",
    "location": "https://github.com/myorg/my-api.git",
    "buildspec": "buildspec.yml",
    "auth": {"type": "OAUTH"}
  }' \
  --artifacts '{
    "type": "S3",
    "location": "my-pipeline-artifacts",
    "packaging": "ZIP",
    "overrideArtifactName": true
  }' \
  --environment '{
    "type": "LINUX_CONTAINER",
    "image": "aws/codebuild/standard:7.0",
    "computeType": "BUILD_GENERAL1_MEDIUM",
    "privilegedMode": true,
    "environmentVariables": [
      {"name": "AWS_ACCOUNT_ID", "value": "123456789012"},
      {"name": "AWS_DEFAULT_REGION", "value": "us-east-1"}
    ]
  }' \
  --service-role $CB_ROLE_ARN \
  --logs-config '{
    "cloudWatchLogs": {
      "status": "ENABLED",
      "groupName": "/aws/codebuild/my-api-build"
    }
  }'

privilegedMode: true is required for Docker-in-Docker builds. Without it, docker build fails with permission denied. Only enable it when your buildspec actually runs Docker.

BUILD_GENERAL1_MEDIUM provides 7 GB RAM and 4 vCPUs. For compute-intensive builds (running test suites, large compilations), BUILD_GENERAL1_LARGE (15 GB, 8 vCPUs) is worth the 2x cost increase if it cuts build time significantly. CodeBuild pricing: Medium = $0.01/minute, Large = $0.02/minute.

CodePipeline: Complete Pipeline

# Create artifact S3 bucket
aws s3 mb s3://my-pipeline-artifacts-${AWS_ACCOUNT_ID}

# Create pipeline role
CP_ROLE_ARN=$(aws iam create-role \
  --role-name CodePipelineRole \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"codepipeline.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
  --query 'Role.Arn' --output text)

# Create the pipeline
aws codepipeline create-pipeline \
  --pipeline '{
    "name": "my-api-pipeline",
    "roleArn": "'"$CP_ROLE_ARN"'",
    "artifactStore": {
      "type": "S3",
      "location": "my-pipeline-artifacts-123456789012"
    },
    "stages": [
      {
        "name": "Source",
        "actions": [{
          "name": "GitHub",
          "actionTypeId": {
            "category": "Source",
            "owner": "ThirdParty",
            "provider": "GitHub",
            "version": "1"
          },
          "configuration": {
            "Owner": "myorg",
            "Repo": "my-api",
            "Branch": "main",
            "OAuthToken": "",
            "PollForSourceChanges": "false"
          },
          "outputArtifacts": [{"name": "SourceArtifact"}]
        }]
      },
      {
        "name": "Build",
        "actions": [{
          "name": "BuildAndTest",
          "actionTypeId": {
            "category": "Build",
            "owner": "AWS",
            "provider": "CodeBuild",
            "version": "1"
          },
          "configuration": {
            "ProjectName": "my-api-build"
          },
          "inputArtifacts": [{"name": "SourceArtifact"}],
          "outputArtifacts": [{"name": "BuildArtifact"}]
        }]
      },
      {
        "name": "ApproveProduction",
        "actions": [{
          "name": "ManualApproval",
          "actionTypeId": {
            "category": "Approval",
            "owner": "AWS",
            "provider": "Manual",
            "version": "1"
          },
          "configuration": {
            "NotificationArn": "arn:aws:sns:us-east-1:123456789012:pipeline-approvals",
            "CustomData": "Review build artifacts before production deploy",
            "ExternalEntityLink": "https://argocd.internal.example.com"
          }
        }]
      },
      {
        "name": "Deploy",
        "actions": [{
          "name": "DeployToECS",
          "actionTypeId": {
            "category": "Deploy",
            "owner": "AWS",
            "provider": "ECS",
            "version": "1"
          },
          "configuration": {
            "ClusterName": "production",
            "ServiceName": "my-api",
            "FileName": "imagedefinitions.json",
            "DeploymentTimeout": "15"
          },
          "inputArtifacts": [{"name": "BuildArtifact"}]
        }]
      }
    ]
  }'

The manual approval stage pauses the pipeline until someone clicks Approve in the console (or via API). The SNS notification sends an email to the ops team. The ExternalEntityLink opens ArgoCD or your deployment dashboard in a new tab during the approval — useful context for the approver.

DeploymentTimeout: 15 gives the ECS service 15 minutes to complete the rolling update. If it doesn’t stabilize in time, CodePipeline marks the action as failed. The ECS service itself handles rollback based on its deployment configuration.

CodeBuild with Webhooks (Pull Request Builds)

# Create a webhook for GitHub PR builds
aws codebuild create-webhook \
  --project-name my-api-build \
  --filter-groups '[[
    {"type": "EVENT", "pattern": "PULL_REQUEST_CREATED,PULL_REQUEST_UPDATED"},
    {"type": "BASE_REF", "pattern": "^refs/heads/main$"}
  ],[
    {"type": "EVENT", "pattern": "PUSH"},
    {"type": "HEAD_REF", "pattern": "^refs/heads/main$"}
  ]]'

This webhook triggers builds on PRs targeting main (for CI verification) and on direct pushes to main (for the deployment pipeline). The filter groups use OR logic between groups and AND logic within a group.

Cross-Account Deployment

Deploying to a production account that’s separate from where the pipeline runs requires cross-account IAM roles:

# In the production account: create a role that CodePipeline can assume
aws iam create-role \
  --role-name CodePipelineCrossAccountRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::TOOLS_ACCOUNT_ID:role/CodePipelineRole"
      },
      "Action": "sts:AssumeRole"
    }]
  }' \
  --profile production-account

# Grant the cross-account role ECS deploy permissions
aws iam attach-role-policy \
  --role-name CodePipelineCrossAccountRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonECS_FullAccess \
  --profile production-account

# In the pipeline definition, add roleArn to the deploy action
# "configuration": {
#   "ClusterName": "production",
#   "ServiceName": "my-api",
#   "FileName": "imagedefinitions.json"
# },
# "roleArn": "arn:aws:iam::PROD_ACCOUNT_ID:role/CodePipelineCrossAccountRole"

The KMS key on the artifact bucket must also allow the cross-account role to decrypt artifacts. This is the most common cross-account pipeline gotcha — builds succeed but the deploy stage fails with KMS access denied.

Pipeline Notifications

# Create SNS topic for pipeline notifications
TOPIC_ARN=$(aws sns create-topic \
  --name pipeline-notifications \
  --query 'TopicArn' --output text)

# Subscribe ops team email
aws sns subscribe \
  --topic-arn $TOPIC_ARN \
  --protocol email \
  --notification-endpoint [email protected]

# Create notification rule for pipeline events
aws codestar-notifications create-notification-rule \
  --name my-api-pipeline-notifications \
  --detail-type FULL \
  --resource arn:aws:codepipeline:us-east-1:123456789012:my-api-pipeline \
  --event-type-ids \
    codepipeline-pipeline-pipeline-execution-failed \
    codepipeline-pipeline-pipeline-execution-succeeded \
    codepipeline-pipeline-manual-approval-needed \
  --targets '[{"TargetType": "SNS", "TargetAddress": "'"$TOPIC_ARN"'"}]'

Pipeline failure notifications are table stakes. Manual approval notifications are what actually make approval stages practical — without them, pipelines sit waiting indefinitely because approvers don’t know to check.

CodePipeline vs GitHub Actions

CodePipeline is the better choice when: you need cross-account AWS deployments with IAM role chaining, your source is CodeCommit (internal, no external dependencies), compliance requires all CI/CD activity to stay within your AWS account, or you need fine-grained IAM control over what the pipeline can do.

GitHub Actions is better when: your team is already on GitHub, you want faster iteration on CI configuration, you need the ecosystem of 20,000+ available actions, or developer experience on pull requests matters (GitHub Actions status checks are more integrated into the GitHub UI).

The practical answer for most teams: GitHub Actions for CI (build, test, lint) with a CodePipeline trigger for the deployment stages. The GitHub Actions deploy to AWS guide covers the GitHub Actions side of this split. For the ECS service being deployed by this pipeline, the ECS Fargate guide covers the service configuration including deployment circuit breakers that work alongside CodePipeline’s timeout-based failure detection.

Bits Lovers

Bits Lovers

Professional writer and blogger. Focus on Cloud Computing.

Comments

comments powered by Disqus