Skip to content

Cross-account CodeBuild — build once in CI, deploy to many environments

Build artifacts in a shared CI account and deploy to per-environment accounts. IAM role assumption, KMS key sharing, ECR replication, S3 bucket policies — with real commands.

The team’s AWS Organizations layout has four accounts — shared-ci, dev, staging, prod. The build pipeline currently runs three times: once per environment, each in its own account, each pulling the same Docker base image, running the same tests, producing artifacts that should be identical but aren’t always. The right shape is a single build in shared-ci that produces one artifact, and three deploy jobs that promote that same artifact across accounts. This article is the IAM, KMS, ECR, and S3 plumbing that makes it work.

What’s happening

The build-once-deploy-many pattern relies on four pieces of cross-account access:

  1. IAM role assumption — CodeBuild in shared-ci assumes a deploy role in dev/staging/prod to apply changes there.
  2. KMS key sharing — CI artifacts encrypted with a CMK in shared-ci need to be decryptable by the target accounts.
  3. ECR cross-account access — the Docker image pushed to shared-ci’s ECR needs to be pullable by ECS/EKS in target accounts. Either via ECR replication (one image, copied to per-account registries) or via ECR repository policies (single image, multiple pull permissions).
  4. S3 bucket policies — when CodePipeline crosses accounts, the artifact bucket needs to allow the target account’s pipeline role to read it.

The pattern that scales: artifacts live in the CI account, are encrypted with the CI account’s KMS key, and target accounts assume a role that has decrypt permissions on that key and read permissions on the bucket. Promotion is “redeploy with the prod-account-targeting role” not “rebuild for prod.”

The promotion looks like:

  • shared-ci build → produces imageDetail.json, ECR image at 123-ci.dkr.ecr.us-east-1.amazonaws.com/web:abc1234
  • dev deploy → assumes arn:aws:iam::111-dev:role/PipelineDeployRole, applies ECS service update pointing at the CI image
  • staging deploy → assumes arn:aws:iam::222-staging:role/PipelineDeployRole, same image
  • prod deploy → assumes arn:aws:iam::333-prod:role/PipelineDeployRole, same image

The same image SHA flows from build to prod. Auditability is straightforward — aws ecr describe-images in shared-ci shows which SHA was deployed where, with the image’s pushedAt and the deploy time as the only two timestamps that matter.

How to verify

The minimum IAM plumbing. In the target account (prod shown; same pattern for dev/staging), create the deploy role:

aws iam create-role \
  --role-name PipelineDeployRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::CI-ACCOUNT-ID:role/CodeBuildServiceRole" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": { "sts:ExternalId": "prod-deploy-2026" }
      }
    }]
  }'

The ExternalId is the canonical confused-deputy mitigation — even if the CI account’s role gets compromised, the attacker can’t assume the target role without knowing the external ID.

Attach the deploy role’s permissions — for an ECS-based service:

aws iam attach-role-policy \
  --role-name PipelineDeployRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

aws iam put-role-policy \
  --role-name PipelineDeployRole \
  --policy-name DeployServices \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Action": [
          "ecs:UpdateService",
          "ecs:DescribeServices",
          "ecs:RegisterTaskDefinition"
        ],
        "Resource": "*"
      },
      {
        "Effect": "Allow",
        "Action": "iam:PassRole",
        "Resource": "arn:aws:iam::PROD-ACCOUNT-ID:role/ecsTaskExecutionRole"
      }
    ]
  }'

In the CI account, the CodeBuild role needs to be allowed to assume each target role:

aws iam put-role-policy \
  --role-name CodeBuildServiceRole \
  --policy-name AssumeDeployRoles \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": [
        "arn:aws:iam::DEV-ACCOUNT-ID:role/PipelineDeployRole",
        "arn:aws:iam::STAGING-ACCOUNT-ID:role/PipelineDeployRole",
        "arn:aws:iam::PROD-ACCOUNT-ID:role/PipelineDeployRole"
      ]
    }]
  }'

KMS sharing — the CMK that encrypts the artifact bucket in shared-ci must let target-account roles decrypt:

aws kms put-key-policy \
  --key-id arn:aws:kms:us-east-1:CI-ACCOUNT-ID:key/abc12345-... \
  --policy-name default \
  --policy '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "EnableIAMUserPermissions",
        "Effect": "Allow",
        "Principal": { "AWS": "arn:aws:iam::CI-ACCOUNT-ID:root" },
        "Action": "kms:*",
        "Resource": "*"
      },
      {
        "Sid": "AllowCrossAccountDecrypt",
        "Effect": "Allow",
        "Principal": {
          "AWS": [
            "arn:aws:iam::DEV-ACCOUNT-ID:role/PipelineDeployRole",
            "arn:aws:iam::STAGING-ACCOUNT-ID:role/PipelineDeployRole",
            "arn:aws:iam::PROD-ACCOUNT-ID:role/PipelineDeployRole"
          ]
        },
        "Action": ["kms:Decrypt", "kms:DescribeKey"],
        "Resource": "*"
      }
    ]
  }'

The S3 artifact bucket in shared-ci needs a policy granting read to target-account principals:

aws s3api put-bucket-policy \
  --bucket shared-ci-pipeline-artifacts \
  --policy '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::DEV-ACCOUNT-ID:role/PipelineDeployRole",
          "arn:aws:iam::STAGING-ACCOUNT-ID:role/PipelineDeployRole",
          "arn:aws:iam::PROD-ACCOUNT-ID:role/PipelineDeployRole"
        ]
      },
      "Action": ["s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::shared-ci-pipeline-artifacts",
        "arn:aws:s3:::shared-ci-pipeline-artifacts/*"
      ]
    }]
  }'

ECR — the image lives in the CI account. Target accounts pull it. Either replicate it (one image, copies in each target’s ECR) or share the source repo:

aws ecr set-repository-policy \
  --repository-name web \
  --policy-text '{
    "Version": "2012-10-17",
    "Statement": [{
      "Sid": "CrossAccountPull",
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::DEV-ACCOUNT-ID:root",
          "arn:aws:iam::STAGING-ACCOUNT-ID:root",
          "arn:aws:iam::PROD-ACCOUNT-ID:root"
        ]
      },
      "Action": [
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:BatchCheckLayerAvailability"
      ]
    }]
  }'

In the target account’s ECS task definition, reference the CI account’s ECR URL directly: 123-ci.dkr.ecr.us-east-1.amazonaws.com/web:abc1234. The ECS task execution role in the target account needs ecr:GetAuthorizationToken (account-level, no resource ARN possible) and ecr:BatchGetImage on the source repo. The source repo’s policy above grants the latter.

In the buildspec, the deploy step assumes the target role and acts as it:

post_build:
  commands:
    - |
      ROLE_OUTPUT=$(aws sts assume-role \
        --role-arn arn:aws:iam::PROD-ACCOUNT-ID:role/PipelineDeployRole \
        --role-session-name codebuild-deploy-${CODEBUILD_BUILD_NUMBER} \
        --external-id prod-deploy-2026)
      export AWS_ACCESS_KEY_ID=$(echo $ROLE_OUTPUT | jq -r .Credentials.AccessKeyId)
      export AWS_SECRET_ACCESS_KEY=$(echo $ROLE_OUTPUT | jq -r .Credentials.SecretAccessKey)
      export AWS_SESSION_TOKEN=$(echo $ROLE_OUTPUT | jq -r .Credentials.SessionToken)
    - aws ecs update-service
        --cluster prod-web
        --service api
        --task-definition api:$NEW_REVISION
        --force-new-deployment

The unset of the session credentials between accounts matters — multiple deploys in one buildspec must re-assume per target to avoid cross-contamination:

unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

The fix

The pattern that scales to a 10-account organization:

  1. One build account, N deploy accounts. Don’t run CodeBuild in each target account. Source code is built once. Promotion is redeployment of the same artifact, not rebuilding.

  2. Per-target role with external ID. Every target account has a PipelineDeployRole trusted to the CI account’s CodeBuild service role, gated by a unique ExternalId per target. The IDs go in SSM Parameter Store in the CI account, not the buildspec.

  3. KMS on the CI account. The artifact bucket’s CMK lives in shared-ci. Target accounts’ roles get kms:Decrypt and kms:DescribeKey on that one key, not their own. Centralizing the key simplifies rotation.

  4. ECR cross-account pull, not replication, by default. Replication doubles the storage cost and adds rotation complexity. The repository policy granting pull to target accounts is simpler and the image SHA is verifiably the same. Use replication only when the latency or cost of pulling across regions hurts (cross-region replication is a separate, valid use).

  5. unset credentials between target assumes. A buildspec that deploys to all three environments in sequence will silently deploy staging to prod if you forget to unset between roles. Make the unset a discipline.

  6. Permissions boundaries on the deploy role. The PipelineDeployRole in prod is powerful (it can update production services). Attach a permissions boundary that caps what it can do — no IAM writes, no security-group rules, no Route 53. The buildspec rarely needs anything beyond ECS/EKS/Lambda update.

  7. CloudTrail in all accounts, aggregated. Cross-account assume-role calls land in the source account’s CloudTrail. The target account’s CloudTrail shows the AssumedRole identity acting locally. An aggregated trail in a security account is the auditable record of every cross-account action.

Common failure mode: the deploy succeeds against an in-account image but fails when the image is in shared-ci. The cause is almost always the ECS task execution role missing ecr:BatchGetImage for the cross-account repo, even though the repo policy grants it. Both sides of the policy chain must allow the pull — the resource policy on the ECR repo and the identity policy on the execution role.


Stack Harbor runs CodeBuild + CodePipeline at production scale as part of Managed AWS onboarding and ongoing Managed Cloud Ops. If your build runs three times when it should run once and your auditors keep asking which SHA shipped to prod, start with an infrastructure consult.