Deleting the stack should leave nothing behind. But S3 refuses to delete a bucket that still holds objects, and both the website bucket (post 7) and the artifact bucket (post 9) can be full. The website bucket is versioned too, so it may carry old versions and delete markers even when it looks empty.
A Lambda-backed custom resource fixes this. CloudFormation invokes a function during stack lifecycle events. On delete, the function empties the bucket, then CloudFormation deletes the now-empty bucket. One function, pointed at both buckets.
The Execution Role
The function lists and deletes objects in both buckets and writes its own logs. It is created under the /site/ path like every service role in this stack, so the Git Sync role from post 3 can provision it.
EmptyBucketRole:
Type: 'AWS::IAM::Role'
Properties:
Path: /site/
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: 'sts:AssumeRole'
Condition:
StringEquals:
'aws:SourceAccount': !Sub '${AWS::AccountId}'
Policies:
- PolicyName: EmptyBucketAccess
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- 's3:ListBucket'
- 's3:ListBucketVersions'
Resource:
- !GetAtt PipelineArtifactBucket.Arn
- !GetAtt S3WebsiteBucket.Arn
- Effect: Allow
Action:
- 's3:DeleteObject'
- 's3:DeleteObjectVersion'
Resource:
- !Sub '${PipelineArtifactBucket.Arn}/*'
- !Sub '${S3WebsiteBucket.Arn}/*'
- Effect: Allow
Action:
- 'logs:CreateLogGroup'
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-EmptyBucket*'
Least privilege: list and delete on both bucket ARNs, nothing read or written, and logs scoped to this function’s log group.
The Function
EmptyBucketFunction runs inline Python 3.12 via ZipFile, so the whole thing stays in one template. It is hardened against a specific failure mode, covered right after the code.
EmptyBucketFunction:
Type: 'AWS::Lambda::Function'
Properties:
FunctionName: !Sub '${AWS::StackName}-EmptyBucket'
Runtime: python3.12
Handler: index.handler
Timeout: 300
Role: !GetAtt EmptyBucketRole.Arn
Code:
ZipFile: |
import json
import time
import urllib.request
import boto3
from botocore.exceptions import ClientError
MIN_REMAINING_MILLIS = 20000
SEND_RESPONSE_ATTEMPTS = 3
def put_response(event, context, status, reason, physical_id):
body = json.dumps({
"Status": status,
"Reason": reason or f"See CloudWatch Log Stream: {context.log_stream_name}",
"PhysicalResourceId": physical_id,
"StackId": event["StackId"],
"RequestId": event["RequestId"],
"LogicalResourceId": event["LogicalResourceId"],
}).encode("utf-8")
req = urllib.request.Request(
event["ResponseURL"],
data=body,
headers={"Content-Type": "", "Content-Length": str(len(body))},
method="PUT",
)
urllib.request.urlopen(req)
def send_response(event, context, status, reason, physical_id):
for attempt in range(1, SEND_RESPONSE_ATTEMPTS + 1):
try:
put_response(event, context, status, reason, physical_id)
return
except Exception as e:
print(f"send_response attempt {attempt} failed: {e}")
if attempt == SEND_RESPONSE_ATTEMPTS:
raise
time.sleep(attempt)
def handler(event, context):
bucket = (event.get("ResourceProperties") or {}).get("BucketName")
physical_id = event.get("PhysicalResourceId") or f"empty-bucket-{bucket}"
if not isinstance(bucket, str) or not bucket:
send_response(event, context, "FAILED", "ResourceProperties.BucketName must be a non-empty string", physical_id)
return
if event["RequestType"] != "Delete":
send_response(event, context, "SUCCESS", "", physical_id)
return
try:
s3 = boto3.client("s3")
paginator = s3.get_paginator("list_object_versions")
for page in paginator.paginate(Bucket=bucket):
if context.get_remaining_time_in_millis() < MIN_REMAINING_MILLIS:
send_response(event, context, "FAILED", "Function timeout approaching before the bucket was fully emptied; retry the stack deletion", physical_id)
return
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:
resp = s3.delete_objects(Bucket=bucket, Delete={"Objects": objects, "Quiet": True})
errors = resp.get("Errors", [])
if errors:
first = errors[0]
send_response(event, context, "FAILED", f"DeleteObjects failed for {len(errors)} object(s); first: {first.get('Key')} {first.get('Code')} {first.get('Message')}", physical_id)
return
send_response(event, context, "SUCCESS", "", physical_id)
except ClientError as e:
print(f"Error emptying bucket: {e}")
if e.response.get("Error", {}).get("Code") == "NoSuchBucket":
send_response(event, context, "SUCCESS", "Bucket already deleted", physical_id)
return
send_response(event, context, "FAILED", str(e), physical_id)
except Exception as e:
print(f"Error emptying bucket: {e}")
send_response(event, context, "FAILED", str(e), physical_id)
The one detail that matters most is the stable PhysicalResourceId. The function reuses event.PhysicalResourceId when present instead of returning something that changes each run. If the id changed between invocations, CloudFormation would read an Update response as a replacement and send a Delete to the “old” id. That Delete empties a bucket. With a live, versioned website bucket in scope now, an unstable id could wipe your site on a routine update. A stable id closes that path.
The rest is defensive hardening:
- Input guard. A missing or empty
BucketNamefails fast with a clear reason. - Timeout guard. Before each page, if under 20 seconds remain, it reports
FAILEDwith a retry message instead of being killed mid-request and hanging the stack. - Error inspection.
delete_objectsreturns per-objectErrors. The function reports the first one rather than claiming success while objects remain. - Delete idempotency. A
NoSuchBucketon delete is already the goal state, so it respondsSUCCESS. - Response retries.
send_responseretries the callback up to three times, since a dropped callback leaves CloudFormation waiting out the custom resource timeout.
It paginates list_object_versions and collects both Versions and DeleteMarkers, so it fully empties a versioned bucket and works on an unversioned one too (every object just comes back with a null version).
The Custom Resources
Two custom resources, one per bucket, both pointed at the same function.
EmptyArtifactBucketOnDelete:
Type: 'Custom::EmptyS3Bucket'
Properties:
ServiceToken: !GetAtt EmptyBucketFunction.Arn
ServiceTimeout: 330
BucketName: !Ref PipelineArtifactBucket
EmptyWebsiteBucketOnDelete:
Type: 'Custom::EmptyS3Bucket'
Properties:
ServiceToken: !GetAtt EmptyBucketFunction.Arn
ServiceTimeout: 330
BucketName: !Ref S3WebsiteBucket
ServiceToken is the function CloudFormation invokes. BucketName passes each bucket through ResourceProperties so the function knows which one to empty.
ServiceTimeout: 330 sits just above the Lambda’s 300-second timeout, so a hung invocation fails fast instead of CloudFormation waiting the default 3600 seconds.
Dependency ordering is automatic. Each BucketName: !Ref makes the custom resource depend on its bucket, so on delete CloudFormation empties the bucket first, then deletes it.