DEV Community

Cover image for AWS CDK 100 Drill Exercises #011: A CodePipeline for CloudFront & S3 — and a self-reference bug

AWS CDK 100 Drill Exercises #011: A CodePipeline for CloudFront & S3 — and a self-reference bug

Level 300

Introduction

This is the 11th installment of "AWS CDK 100 Drill Exercises." See here for an overview of the series.

This time, the pattern itself is a plain one: a CodePipeline that pulls a static site from CodeCommit, builds it with CodeBuild, uploads it to S3, and invalidates CloudFront. Nothing exotic. What made this one worth writing up is what happened when I actually wired the test suite up and ran it for the first time: the synthesized CloudFormation template came back with

Template is undeployable, these resources have a dependency cycle: ... -> PipelineXXXX -> PipelineXXXX
Enter fullscreen mode Exit fullscreen mode

The pipeline was referencing itself.

What you'll learn in this article

  • A CodePipeline with no hand-written pipeline or build role — each pipeline action grants itself only the resource-scoped permissions it needs (the invoked Lambdas still carry their own execution-role policies)
  • Why "upload" and "delete stale files" are worth splitting into two separate pipeline stages instead of one aws s3 sync --delete
  • The CodePipeline continuation-token pattern for polling a long-running operation (CloudFront invalidation) without blocking a Lambda invocation
  • How a CDK token used inside a pipeline's own stage configuration can create a genuine CloudFormation self-reference cycle — and how to avoid it
  • Two more deploy-time landmines that only show up once you actually run Template.fromStack(): an S3 bucket name that isn't lowercase, and a notification rule with zero targets

📁 Code repository: GitHub


Architecture Overview

Architecture Overview

Feature Benefit
No custom pipeline/build IAM roles Every action grants itself exactly the resource-scoped permissions it needs when bound to the pipeline
Upload/cleanup split across stages S3DeployAction uploads; a dedicated Lambda removes what's no longer in the build
Continuation-token invalidation CloudFront invalidation status is polled via CodePipeline's own re-invocation mechanism, not a blocking wait
Opt-in approval + notifications A single approvalTopicArn parameter turns on both a Manual Approval stage and pipeline-result notifications

Data Flow

CodeCommit (push to branch)
    │  EventBridge rule triggers the pipeline
    ▼
CodePipeline
  ├─ Source              : CodeCommitSourceAction → SourceOutput artifact
  ├─ Build (optional)    : CodeBuildAction (buildspec.yml) → BuildOutput artifact   ← enabled by envParams.enableBuild
  ├─ Approval (optional) : ManualApprovalAction, notifies approvalTopicArn
  ├─ Deploy              : S3DeployAction → upload the build to the target bucket
  ├─ Sync                : Lambda → delete target-bucket objects not present in the build
  └─ InvalidateCache     : Lambda → CloudFront CreateInvalidation, polled via continuation token
Enter fullscreen mode Exit fullscreen mode

The Build stage is opt-in (envParams.enableBuild, off by default in the reference config). When it's disabled, the source artifact is deployed to S3 directly and the Deploy/Sync stages consume SourceOutput instead of BuildOutput.


Implementation Highlights

1. Let CDK grant IAM permissions per action — don't hand-roll a pipeline role

Every CodePipeline action construct (CodeCommitSourceAction, CodeBuildAction, S3DeployAction, LambdaInvokeAction) grants itself the exact, resource-scoped permissions it needs the moment it's bound to a stage. So the pipeline is created with no custom role at all:

const pipeline = new codepipeline.Pipeline(this, 'Pipeline', {
  pipelineName: pipelineName,
  restartExecutionOnUpdate: true,
  artifactBucket: artifactBucket,
  // no `role` — CDK creates one and grants each action's permissions as stages are added
});
Enter fullscreen mode Exit fullscreen mode

This reference implementation originally had a hand-written pipeline role with artifactBucket.grantReadWrite(), repository.grantRead(), and three addToPolicy() calls including codedeploy:* and codestar-notifications:* on Resource: '*'. None of those wildcard grants were actually needed — nothing in the pipeline used CodeDeploy, and CodeStarNotifications doesn't check permissions on the pipeline's own role at all. Once I traced through what each action construct grants on bind() (CodeBuildAction scopes codebuild:BatchGetBuilds/StartBuild/StopBuild to the project ARN, S3DeployAction calls bucket.grantWrite() on the deploy target and grantRead() on the artifact bucket, LambdaInvokeAction calls lambda.grantInvoke(), and the Pipeline construct itself calls artifactBucket.grantReadWrite(this.role) internally), the entire hand-written pipeline role and its policies could just be deleted.

The two Lambdas the pipeline invokes are a separate matter: their execution roles still get explicit, resource-scoped policies in the stack (s3:ListBucket/GetObject/PutObject/DeleteObject on the target bucket for the sync Lambda; cloudfront:CreateInvalidation/GetInvalidation and sns:Publish for the invalidation Lambda), plus an artifactBucket.grantRead() so the sync Lambda can read the input artifact. LambdaInvokeAction only grants the pipeline role permission to invoke the function — it doesn't touch the function's own role.

2. Split "upload" from "cleanup" instead of one aws s3 sync --delete

// Deploy stage: upload/overwrite only
new codepipeline_action.S3DeployAction({
  actionName: 'S3_Deploy',
  bucket: cdk.aws_s3.Bucket.fromBucketName(this, 'TargetBucket', props.envParams.deploymentTargetBucketName),
  input: buildOutput,
});

// Sync stage: a separate Lambda removes stale objects afterward
new codepipeline_action.LambdaInvokeAction({
  actionName: 'Lambda_S3_Sync',
  lambda: s3SyncLambda,
  inputs: [buildOutput],
  userParameters: { DEST_BUCKET_NAME: props.envParams.deploymentTargetBucketName },
});
Enter fullscreen mode Exit fullscreen mode

S3DeployAction is a managed action, so the common "upload the build" case needs no custom code at all. Deletion — which requires diffing the new build's file list against what's already in the bucket — is isolated in its own Lambda, testable and replaceable independently of the upload step.

3. The continuation-token pattern for CloudFront invalidation

CloudFront invalidations can take minutes to complete. Instead of a Lambda that polls in a loop until it's done (risking a timeout), the invalidation Lambda reports a continuationToken back to CodePipeline (via put_job_success_result), and CodePipeline re-invokes it until it reports success:

def lambda_handler(event, context):
    job_id = event['CodePipeline.job']['id']
    job_data = event['CodePipeline.job']['data']

    if 'continuationToken' in job_data:
        # Re-invoked: check on the invalidation we created last time
        continuation_token = json.loads(job_data['continuationToken'])
        invalidation_id = continuation_token['InvalidationId']
        status = monitor_invalidation_state(distribution_id, invalidation_id)
        if status != 'Completed':
            continue_job_later(job_id, invalidation_id)  # ask CodePipeline to call us again
        else:
            put_job_success(job_id)
    else:
        # First invocation: create the invalidation and ask to be re-invoked
        invalidation_id = create_invalidation(distribution_id)
        continue_job_later(job_id, invalidation_id)
Enter fullscreen mode Exit fullscreen mode

Each invocation is short-lived; CodePipeline itself handles the "come back later and check again" scheduling. On completion (or failure) the Lambda also publishes a short status message to a dedicated SNS topic the stack always creates — that notification is independent of the opt-in approvalTopicArn feature below.

4. The bug: a pipeline that referenced itself

The InvalidateCache Lambda's userParameters originally included the pipeline's own name, read straight off the construct:

// Before — looks harmless
userParameters: {
  "PIPELINE_NAME": pipeline.pipelineName,
  "DISTRIBUTION_ID": props.envParams.cloudfrontDistributionId,
}
Enter fullscreen mode Exit fullscreen mode

pipeline.pipelineName is a CDK token that resolves to { "Ref": "PipelineXXXX" }. And this userParameters blob is itself embedded inside one of the Pipeline resource's own Stages[].Actions[].Configuration properties — because the InvalidateCache action is a stage of that very pipeline. The rendered template ends up with the Pipeline resource containing a Ref to itself, which Template.fromStack()'s cyclic-dependency checker correctly rejects:

Template is undeployable, these resources have a dependency cycle: ... -> PipelineXXXX -> PipelineXXXX
Enter fullscreen mode Exit fullscreen mode

I never hit this while the stack only existed in bin/, because nothing ever called Template.fromStack() on it. The moment I wrote a real snapshot test, it surfaced immediately. The fix is to compute the name as a plain string before constructing the Pipeline, and reuse that instead of the token:

// Fixed — a real string, not a Ref back to the Pipeline resource
const pipelineName = `${props.project}-${props.environment}-pipeline`;

const pipeline = new codepipeline.Pipeline(this, 'Pipeline', {
  pipelineName: pipelineName,
  // ...
});

// later, in the InvalidateCache stage:
userParameters: {
  "PIPELINE_NAME": pipelineName, // plain string, no cycle
  "DISTRIBUTION_ID": props.envParams.cloudfrontDistributionId,
}
Enter fullscreen mode Exit fullscreen mode

The general lesson: any token that resolves to Ref/Fn::GetAtt on a construct is safe to use in most places, but if it ends up embedded inside that same construct's own properties (because the reference lives inside an action/stage that belongs to that construct), CloudFormation sees a resource pointing at itself. If you already have the plain value on hand before construction, prefer it.

5. Two more bugs that only a real Template.fromStack() run would catch

An S3 bucket name that wasn't guaranteed lowercase.

// Before
bucketName: `${props.project}-${props.environment}-artifact-bucket`,
Enter fullscreen mode Exit fullscreen mode

S3 bucket names must be lowercase. A props.project of "TestProject" produced InvalidBucketNameValue at synth time. The fix, matching the convention already used elsewhere in this repo:

bucketName: `${props.project}-${props.environment}-artifact-bucket`.toLowerCase(),
Enter fullscreen mode Exit fullscreen mode

A NotificationRule that could get zero targets.

The pipeline notification rule is only meaningful when an approval/notification topic is configured. The original code always created it, with an empty targets: [] when no topic was set:

// Before — deploys fine when a topic is set, fails when it isn't
new codestar_notification.NotificationRule(this, 'PipelineNotificationRule', {
  // ...
  targets: props.envParams.approvalTopicArn
    ? [sns.Topic.fromTopicArn(this, 'NotificationTopic', props.envParams.approvalTopicArn)]
    : [],
});
Enter fullscreen mode Exit fullscreen mode

AWS::CodeStarNotifications::NotificationRule requires at least one target — an empty list deploys to a CloudFormation error, not a synth-time one, which makes it even easier to miss in local testing. The fix is to skip creating the resource entirely when there's no topic to notify:

if (props.envParams.approvalTopicArn) {
  new codestar_notification.NotificationRule(this, 'PipelineNotificationRule', {
    // ...
    targets: [sns.Topic.fromTopicArn(this, 'NotificationTopic', props.envParams.approvalTopicArn)],
  });
}
Enter fullscreen mode Exit fullscreen mode

The same if also gates the Manual Approval stage, so a single optional parameter (approvalTopicArn) turns the entire "gate deployments and notify someone" feature on or off per environment.


Deploy & Verify

# The npm scripts read $PROJECT / $ENV and target the AWS named profile ${PROJECT}-${ENV}
export PROJECT=your-project
export ENV=dev

npm run bootstrap   # first time only
npm run deploy:all
Enter fullscreen mode Exit fullscreen mode
# Push to the configured branch to trigger the pipeline, then check its status
aws codepipeline get-pipeline-state --name <project>-<env>-pipeline
Enter fullscreen mode Exit fullscreen mode

Cost Estimate

💰 Rough monthly estimate (Tokyo region, ~20 pipeline executions/month)

Service Usage Rough monthly cost
CodePipeline (V2 pricing) ~20 runs ~$0.40
CodeBuild (BUILD_GENERAL1_SMALL, ~3 min/build) — only when enableBuild ~20 builds ~$0.30
Lambda (Sync + Invalidate) Light invocations ~$0.05
S3 artifact bucket A few MB < $0.05
CloudWatch Logs (2 log groups, or 3 with the build stage; 1-week retention) Light volume < $0.05

Total: roughly $1/month with the build stage enabled, ~$0.55 without

Cost scales with pipeline executions, not idle time — nothing runs between deploys.


Summary

What we learned from this pattern:

  1. Let CDK's action constructs grant their own IAM permissions. A hand-written pipeline role tends to accumulate wildcard grants that nothing in the pipeline actually uses
  2. Split "upload" and "cleanup" across stages when a managed action (S3DeployAction) covers the common case and only the edge case (stale-object deletion) needs custom code
  3. The continuation-token pattern lets a Lambda-backed pipeline action represent a long-running operation without blocking on it
  4. A token that resolves to a construct's own Ref can create a self-reference if it ends up embedded inside that construct's own properties — compute plain values ahead of time when they're going to be reused inside the same construct's actions
  5. Template.fromStack() is the fastest way to catch deploy-time-only failures (bucket naming rules, resources with mandatory non-empty properties, dependency cycles) — none of the three bugs in this article showed up until the test suite actually synthesized and validated the template
  6. Guard optional child resources with the same condition as the feature they belong to — a NotificationRule with zero targets and a Manual Approval stage that's never wired to a topic are two symptoms of the same missing guard

References


Let's keep learning practical AWS CDK patterns through the 100 drill exercises!
If you found this helpful, please ⭐ the repository!

📌 You can see the entire code in my GitHub repository.

Top comments (0)