The most surprising thing about CDK custom resources is that they don’t actually run code within your CloudFormation stack.

Let’s see this in action. Imagine we want to provision a resource that CloudFormation doesn’t natively support, like a specific type of managed policy with certain tags. We’ll use a Lambda function to do the heavy lifting.

First, we define our Lambda function. This function will contain the logic to create, update, or delete our custom resource.

# lambda_handler.py
import json
import boto3

def lambda_handler(event, context):
    print(f"Received event: {json.dumps(event)}")

    request_type = event['RequestType']
    props = event['ResourceProperties']
    physical_resource_id = event.get('PhysicalResourceId')

    response_data = {}
    status = 'SUCCESS'
    reason = 'See the details in CloudWatch Logs.'

    try:
        if request_type == 'Create':
            # Logic to create the custom resource
            policy_name = props['PolicyName']
            tags = props.get('Tags', {})
            iam = boto3.client('iam')

            # Example: Creating a managed policy (simplified)
            response = iam.create_policy(
                PolicyName=policy_name,
                PolicyDocument='{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}]}',
                Description='Managed by CDK Custom Resource',
                Tags=[{'Key': k, 'Value': v} for k, v in tags.items()]
            )
            physical_resource_id = response['Policy']['Arn']
            response_data['Arn'] = physical_resource_id
            print(f"Created policy with ARN: {physical_resource_id}")

        elif request_type == 'Update':
            # Logic to update the custom resource
            # For this example, we'll assume updates are not supported or handled by re-creation
            print(f"Update requested for {physical_resource_id}. No-op for this example.")
            pass

        elif request_type == 'Delete':
            # Logic to delete the custom resource
            if physical_resource_id:
                arn_parts = physical_resource_id.split(':')
                policy_name_to_delete = arn_parts[-1] # Extract policy name from ARN
                iam = boto3.client('iam')
                try:
                    iam.delete_policy(PolicyArn=physical_resource_id)
                    print(f"Deleted policy with ARN: {physical_resource_id}")
                except iam.exceptions.NoSuchEntityException:
                    print(f"Policy {physical_resource_id} not found, skipping deletion.")
            else:
                print("No PhysicalResourceId provided for delete.")

    except Exception as e:
        print(f"Error processing request: {e}")
        status = 'FAILED'
        reason = str(e)

    # Send the response back to CloudFormation
    cfnresponse = boto3.client('cloudformation')
    cfnresponse.send(
        StackId=event['StackId'],
        RequestId=event['RequestId'],
        LogicalResourceId=event['LogicalResourceId'],
        PhysicalResourceId=physical_resource_id if status == 'SUCCESS' else f'failed-{event["LogicalResourceId"]}',
        Status=status,
        Reason=reason,
        Data=response_data
    )
    return {
        'statusCode': 200,
        'body': json.dumps('Response sent to CloudFormation')
    }

This Lambda function needs to be deployed. In your CDK app, you’d define it like this:

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

class CustomResourceStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # Define the Lambda function
        custom_resource_handler = lambda_.Function(
            self, "CustomResourceHandler",
            runtime=lambda_.Runtime.PYTHON_3_9,
            handler="lambda_handler.lambda_handler",
            code=lambda_.Code.from_asset("lambda"), # Assuming your lambda_handler.py is in a 'lambda' directory
            timeout=core.Duration.seconds(300)
        )

        # Grant necessary permissions to the Lambda function
        custom_resource_handler.add_to_role_policy(
            iam.PolicyStatement(
                actions=["iam:CreatePolicy", "iam:DeletePolicy"],
                resources=["*"] # In a real scenario, scope this down!
            )
        )

        # Now, create the custom resource itself
        my_managed_policy = core.CustomResource(
            self, "MyCustomIAMPolicy",
            service_token=custom_resource_handler.function_arn,
            properties={
                "PolicyName": "MyCdkManagedPolicy",
                "Tags": {
                    "Environment": "Dev",
                    "ManagedBy": "CDK"
                }
            }
        )

        # You can access attributes returned by the Lambda function
        core.CfnOutput(self, "PolicyArnOutput", value=my_managed_policy.get_att_string("Arn"))

The core.CustomResource construct is the key. It takes a service_token, which is the ARN of the Lambda function that will handle the resource’s lifecycle. When CloudFormation needs to create, update, or delete this "resource," it doesn’t know what to do. Instead, it sends an event payload to the Lambda function specified by service_token.

The Lambda function then receives this event, checks the RequestType (Create, Update, or Delete), and executes the appropriate logic. Crucially, it must send a response back to CloudFormation using the cfnresponse.send SDK call. This response tells CloudFormation whether the operation succeeded or failed, and provides a PhysicalResourceId. The PhysicalResourceId is essential for subsequent updates and deletions; it’s how CloudFormation tracks the actual resource provisioned by your Lambda.

The ResourceProperties passed to core.CustomResource are directly included in the event payload sent to the Lambda. The response_data dictionary returned by the Lambda function can be accessed in your CDK stack using get_att_string on the CustomResource construct.

This mechanism allows you to extend CloudFormation’s capabilities to any AWS service or even external APIs. The Lambda function acts as a bridge, translating CloudFormation’s lifecycle events into API calls or other actions.

The one detail most people find surprising is that the PhysicalResourceId is whatever your Lambda function decides to send back. There’s no automatic mapping or discovery by CloudFormation. If your Lambda fails to send a PhysicalResourceId on a Create event when it should have, CloudFormation will mark the resource as failed.

Once your custom resource is successfully created, the next logical step is to understand how to handle rollbacks when your custom resource operation fails.

Want structured learning?

Take the full Cdk course →