Back to notes

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 BucketName fails fast with a clear reason.
  • Timeout guard. Before each page, if under 20 seconds remain, it reports FAILED with a retry message instead of being killed mid-request and hanging the stack.
  • Error inspection. delete_objects returns per-object Errors. The function reports the first one rather than claiming success while objects remain.
  • Delete idempotency. A NoSuchBucket on delete is already the goal state, so it responds SUCCESS.
  • Response retries. send_response retries 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.