Deleting the CloudFormation stack fails if the PipelineArtifactBucket still has objects. The site bucket uses DeletionPolicy: Retain (post 7), so it stays behind on purpose. The artifact bucket should be deleted with the stack, but S3 refuses to delete a non-empty bucket. CloudFormation needs help emptying it first.
A Lambda-backed custom resource solves this. CloudFormation invokes a Lambda function during stack lifecycle events. On deletion, the function empties the bucket, then CloudFormation deletes the now-empty bucket normally.
IAM Role
The Lambda function needs an IAM role with three sets of permissions: list and delete objects in the artifact bucket, and write logs to CloudWatch.
EmptyArtifactBucketRole:
Type: 'AWS::IAM::Role'
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: 'sts:AssumeRole'
Policies:
- PolicyName: EmptyBucketAccess
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- 's3:ListBucket'
- 's3:ListBucketVersions'
Resource: !GetAtt PipelineArtifactBucket.Arn
- Effect: Allow
Action:
- 's3:DeleteObject'
- 's3:DeleteObjectVersion'
Resource: !Sub '${PipelineArtifactBucket.Arn}/*'
- Effect: Allow
Action:
- 'logs:CreateLogGroup'
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ProjectName}EmptyArtifactBucket*'
The trust policy allows lambda.amazonaws.com to assume this role, same pattern as any Lambda execution role.
The permissions policy follows least-privilege. The first statement grants s3:ListBucket and s3:ListBucketVersions scoped to the bucket ARN itself. The second statement grants s3:DeleteObject and s3:DeleteObjectVersion scoped to objects within the bucket (/*). No read or write permissions are needed. The function only lists and deletes.
The third statement grants CloudWatch Logs permissions scoped to the Lambda’s log group. This follows the same pattern as the CodeBuildServiceRole and DeployServiceRole from earlier posts, where each role’s log permissions are scoped to a specific log group ARN rather than using a wildcard.
The Astro template uses
${AWS::StackName}with hyphens as separators instead of${ProjectName}. For example, the FunctionName becomes!Sub '${AWS::StackName}-EmptyArtifactBucket'and the log group ARN uses${AWS::StackName}-EmptyArtifactBucketaccordingly.
Lambda Function
The EmptyArtifactBucketFunction uses inline Python 3.12 via the ZipFile property. This keeps everything in a single CloudFormation template with no external deployment package.
EmptyArtifactBucketFunction:
Type: 'AWS::Lambda::Function'
Properties:
FunctionName: !Sub '${ProjectName}EmptyArtifactBucket'
Runtime: python3.12
Handler: index.handler
Timeout: 300
Role: !GetAtt EmptyArtifactBucketRole.Arn
Code:
ZipFile: |
import boto3
import json
import urllib.request
def send_response(event, context, status, reason=""):
body = json.dumps({
"Status": status,
"Reason": reason or f"See CloudWatch Log Stream: {context.log_stream_name}",
"PhysicalResourceId": context.log_stream_name,
"StackId": event["StackId"],
"RequestId": event["RequestId"],
"LogicalResourceId": event["LogicalResourceId"],
}).encode("utf-8")
req = urllib.request.Request(
event["ResponseURL"],
data=body,
headers={"Content-Type": ""},
method="PUT",
)
urllib.request.urlopen(req)
def handler(event, context):
if event["RequestType"] != "Delete":
send_response(event, context, "SUCCESS")
return
try:
bucket = event["ResourceProperties"]["BucketName"]
s3 = boto3.client("s3")
paginator = s3.get_paginator("list_object_versions")
for page in paginator.paginate(Bucket=bucket):
objects = []
for v in page.get("Versions", []):
objects.append({"Key": v["Key"], "VersionId": v["VersionId"]})
for m in page.get("DeleteMarkers", []):
objects.append({"Key": m["Key"], "VersionId": m["VersionId"]})
if objects:
s3.delete_objects(Bucket=bucket, Delete={"Objects": objects, "Quiet": True})
send_response(event, context, "SUCCESS")
except Exception as e:
print(f"Error emptying bucket: {e}")
send_response(event, context, "FAILED", str(e))
The function has two parts.
send_response constructs the CloudFormation custom resource response payload and sends an HTTP PUT to the presigned ResponseURL. CloudFormation waits for this callback to know whether the operation succeeded or failed. The payload includes the stack ID, request ID, logical resource ID, and a status of either SUCCESS or FAILED.
handler is the entry point. A guard clause checks the RequestType. On Create or Update, there is nothing to do, so it immediately responds with SUCCESS and returns.
On Delete, the function retrieves the bucket name from ResourceProperties, then paginates through all object versions using list_object_versions. Pagination matters because buckets can accumulate thousands of artifacts over time, and a single list_object_versions call returns at most 1,000 entries. For each page, the function collects both Versions and DeleteMarkers into a single list and calls delete_objects to remove them in bulk.
Both Versions and DeleteMarkers must be deleted. The artifact bucket has versioning enabled (required by CodePipeline), so deleting a current version creates a delete marker rather than truly removing the object. To fully empty a versioned bucket, every version and every delete marker must be removed.
The entire Delete path is wrapped in a try/except. If anything fails, the function sends FAILED with the error string so CloudFormation can report the failure rather than hanging indefinitely.
There is one edge case the try/except cannot cover: if send_response itself fails (for example, a network issue reaching the presigned URL), CloudFormation receives no callback at all and waits until the custom resource timeout (default one hour) before failing the stack operation. This is a known limitation that AWS’s own cfn-response module shares. In practice it is unlikely because the ResponseURL is a presigned S3 endpoint within the AWS network, but it is worth knowing about when debugging stuck stack deletions.
Timeout: 300 gives the function five minutes to empty the bucket. This is generous for most artifact buckets, but if a long-lived stack has accumulated many objects, the extra time prevents a timeout before the function finishes paginating.
Custom Resource
The custom resource ties the Lambda function to the CloudFormation stack lifecycle.
EmptyArtifactBucketOnDelete:
Type: 'Custom::EmptyS3Bucket'
Properties:
ServiceToken: !GetAtt EmptyArtifactBucketFunction.Arn
BucketName: !Ref PipelineArtifactBucket
Type: Custom::EmptyS3Bucket declares a custom resource. Any type name starting with Custom:: tells CloudFormation to treat this as a Lambda-backed custom resource. The suffix (EmptyS3Bucket) is descriptive and has no functional effect.
ServiceToken is the ARN of the Lambda function that CloudFormation invokes on create, update, and delete events.
BucketName passes the artifact bucket’s name to the Lambda function via the ResourceProperties field in the event payload. This is how the function knows which bucket to empty.
CloudFormation handles the dependency ordering automatically. Because BucketName: !Ref PipelineArtifactBucket creates a reference from the custom resource to the bucket, CloudFormation knows the custom resource depends on the bucket. During deletion, CloudFormation processes resources in reverse dependency order: it deletes the custom resource first (which triggers the Lambda to empty the bucket), then deletes the now-empty bucket.
Updating the Git Sync Role
The Git Sync IAM role from post 4 needs additional permissions so CloudFormation can create and manage the Lambda function. Add these actions to the role’s policy:
- Effect: Allow
Action:
- 'lambda:CreateFunction'
- 'lambda:DeleteFunction'
- 'lambda:GetFunction'
- 'lambda:GetFunctionConfiguration'
- 'lambda:InvokeFunction'
- 'lambda:TagResource'
Resource: '*'
The iam:PassRole and iam:CreateRole permissions needed for the Lambda execution role are already granted from post 9, so no changes are needed for those.