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:
- IAM role assumption — CodeBuild in
shared-ciassumes a deploy role indev/staging/prodto apply changes there. - KMS key sharing — CI artifacts encrypted with a CMK in
shared-cineed to be decryptable by the target accounts. - 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). - 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-cibuild → producesimageDetail.json, ECR image at123-ci.dkr.ecr.us-east-1.amazonaws.com/web:abc1234devdeploy → assumesarn:aws:iam::111-dev:role/PipelineDeployRole, applies ECS service update pointing at the CI imagestagingdeploy → assumesarn:aws:iam::222-staging:role/PipelineDeployRole, same imageproddeploy → assumesarn: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:
-
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.
-
Per-target role with external ID. Every target account has a
PipelineDeployRoletrusted to the CI account’s CodeBuild service role, gated by a uniqueExternalIdper target. The IDs go in SSM Parameter Store in the CI account, not the buildspec. -
KMS on the CI account. The artifact bucket’s CMK lives in
shared-ci. Target accounts’ roles getkms:Decryptandkms:DescribeKeyon that one key, not their own. Centralizing the key simplifies rotation. -
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).
-
unsetcredentials between target assumes. A buildspec that deploys to all three environments in sequence will silently deploystagingto prod if you forget to unset between roles. Make the unset a discipline. -
Permissions boundaries on the deploy role. The
PipelineDeployRoleinprodis 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. -
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.