CDK lets you define infrastructure as code, but sometimes CloudFormation doesn’t support the exact AWS resource you need, or it doesn’t support the specific configuration you want. That’s where custom resource providers come in.

Here’s a CDK stack that defines a custom resource provider and then uses it to create an S3 bucket with a specific SSE-KMS encryption configuration that wasn’t directly available in CloudFormation at the time.

from aws_cdk import (
    aws_lambda as lambda_,
    aws_iam as iam,
    custom_resources as cr,
    core
)

class CustomResourceProviderStack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Define the Lambda function that will handle the custom resource requests
        custom_resource_handler = lambda_.Function(
            self, "CustomResourceHandler",
            runtime=lambda_.Runtime.PYTHON_3_9,
            handler="index.handler",
            code=lambda_.Code.from_asset("lambda"), # Assuming your Lambda code is in a 'lambda' directory
            initial_policy=[
                iam.PolicyStatement(
                    actions=["s3:CreateBucket", "s3:PutBucketEncryption", "s3:DeleteBucket", "s3:GetBucketEncryption"],
                    resources=["*"] # In a real-world scenario, scope this down
                )
            ]
        )

        # Define the custom resource provider
        provider = cr.Provider(
            self, "CustomResourceProvider",
            on_event_handler=custom_resource_handler,
            is_complete_handler=custom_resource_handler # For simplicity, using the same handler for isComplete
        )

        # Use the custom resource provider to create an S3 bucket with specific encryption
        bucket_name = "my-special-encrypted-bucket-12345"
        kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/your-kms-key-id" # Replace with your actual KMS key ARN

        core.CustomResource(
            self, "SpecialEncryptedBucket",
            service_token=provider.service_token,
            properties={
                "BucketName": bucket_name,
                "SSEAlgorithm": "aws:kms",
                "KMSMasterKeyID": kms_key_arn
            }
        )

The lambda/index.py would look something like this:

import boto3
import json
import logging
import cfnresponse # Provided by AWS Lambda Custom Resource support

logger = logging.getLogger()
logger.setLevel(logging.INFO)

s3 = boto3.client('s3')

def handler(event, context):
    logger.info(f"Received event: {json.dumps(event)}")
    request_type = event['RequestType']
    response_data = {}
    physical_resource_id = event.get('PhysicalResourceId')

    try:
        props = event['ResourceProperties']
        bucket_name = props['BucketName']
        sse_algorithm = props['SSEAlgorithm']
        kms_master_key_id = props.get('KMSMasterKeyID') # Optional if SSEAlgorithm is not aws:kms

        if request_type == 'Create':
            logger.info(f"Creating bucket: {bucket_name}")
            s3.create_bucket(Bucket=bucket_name)

            if sse_algorithm == 'aws:kms':
                logger.info(f"Applying SSE-KMS encryption to bucket: {bucket_name}")
                s3.put_bucket_encryption(
                    Bucket=bucket_name,
                    ServerSideEncryptionConfiguration={
                        'Rules': [
                            {
                                'ApplyServerSideEncryptionByDefault': {
                                    'SSEAlgorithm': sse_algorithm,
                                    'KMSMasterKeyID': kms_master_key_id
                                }
                            }
                        ]
                    }
                )
            # Add other SSE types as needed
            response_data['BucketName'] = bucket_name
            physical_resource_id = bucket_name # For Create, PhysicalResourceId is often the resource name

        elif request_type == 'Update':
            logger.info(f"Updating bucket: {bucket_name} (PhysicalResourceId: {physical_resource_id})")
            # In a real scenario, you'd compare old and new properties and perform specific updates.
            # For this example, we'll assume updates might involve re-applying encryption if changed.
            if sse_algorithm == 'aws:kms':
                logger.info(f"Re-applying SSE-KMS encryption to bucket: {bucket_name}")
                s3.put_bucket_encryption(
                    Bucket=bucket_name,
                    ServerSideEncryptionConfiguration={
                        'Rules': [
                            {
                                'ApplyServerSideEncryptionByDefault': {
                                    'SSEAlgorithm': sse_algorithm,
                                    'KMSMasterKeyID': kms_master_key_id
                                }
                            }
                        ]
                    }
                )
            response_data['BucketName'] = bucket_name
            # PhysicalResourceId typically remains the same during updates

        elif request_type == 'Delete':
            logger.info(f"Deleting bucket: {bucket_name} (PhysicalResourceId: {physical_resource_id})")
            try:
                # Attempt to remove encryption before deleting the bucket
                s3.delete_bucket_encryption(Bucket=bucket_name)
                logger.info(f"Removed encryption from bucket: {bucket_name}")
            except s3.exceptions.ClientError as e:
                if e.response['Error']['Code'] == 'ClientError': # Or a more specific error for no encryption configured
                    logger.info(f"No encryption found on bucket {bucket_name} to delete.")
                else:
                    raise # Re-raise other errors
            s3.delete_bucket(Bucket=bucket_name)

        cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, physical_resource_id)

    except Exception as e:
        logger.error(f"Error processing event: {e}")
        cfnresponse.send(event, context, cfnresponse.FAILED, {"Error": str(e)}, physical_resource_id)

The core idea is that you’re abstracting away the CloudFormation gap. The cr.Provider resource in CDK sets up the necessary infrastructure (usually a Lambda function and an SNS topic) to receive events from CloudFormation when a CustomResource is created, updated, or deleted. Your Lambda function then performs the actual AWS API calls.

What makes this powerful is that any AWS API call that can be made via the AWS SDK can be exposed as a CloudFormation resource. This unlocks capabilities for resources that are either new, have specific configuration options not yet exposed by CloudFormation, or require a sequence of operations.

The service_token is the key. It’s the ARN of the SNS topic that CloudFormation will publish custom resource events to. Your cr.Provider resource creates this and wires it up to your on_event_handler Lambda. When core.CustomResource is instantiated, CloudFormation sends a Create event to that SNS topic, which triggers your Lambda. Your Lambda performs the action and then signals back to CloudFormation via the cfnresponse module, telling it whether the operation succeeded or failed, and providing a PhysicalResourceId.

This allows you to manage any AWS resource or perform any action as if it were a native CloudFormation resource, all within your CDK application.

The next thing you’ll want to tackle is robust error handling within your Lambda, especially for the Delete operation where resources might already be gone or in an unexpected state.

Want structured learning?

Take the full Cdk course →