How to Extend CloudFormation with Custom Resources or Constructs

June 30, 2027 · 16 min read

Developer · DVA-C02 · part of The Exam Room

The situation

A platform team has three infrastructure extension problems open at once.

  • A Datadog integration: every deployed Lambda should be registered with a Datadog monitor that alerts on p99 latency. Datadog is not an AWS resource; CloudFormation doesn’t know how to create Datadog monitors; the team needs the create-monitor-on-stack-create and delete-monitor-on-stack-delete lifecycle to be tied to the Lambda’s stack.
  • A standard service pattern: the team deploys roughly forty Lambda-plus-API-Gateway-plus-DynamoDB services per year. Every one has the same logging, X-Ray, IAM baseline, tag policy, and dashboard. Today each service’s CDK code is 300 lines; most of those lines are identical between services.
  • A GitHub Actions workflow: the team manages GitHub Actions workflows declaratively alongside infrastructure. Workflows live in .github/workflows/*.yml in the deployed service’s repo; the team wants the infrastructure pipeline to commit the workflow file during deploy.

Two of these call for CloudFormation custom resources. One of them calls for a CDK construct. Getting them confused loses the team weeks.

What actually matters

Before reaching for either, it’s worth naming what each one is.

CloudFormation custom resources extend CloudFormation itself. When CloudFormation encounters a resource of type Custom::<Name> (or AWS::CloudFormation::CustomResource) in a template, it invokes a Lambda function (or publishes to an SNS topic) with the intended operation – Create, Update, or Delete, and a set of input properties. The Lambda does whatever it does (call a third-party API, compute a value, wait for a condition) and signals success or failure back to CloudFormation. The rest of the stack waits. The mechanism’s value is that it gives CloudFormation the ability to manage things CloudFormation itself doesn’t natively understand.

CDK constructs extend CDK, not CloudFormation. A construct is a reusable unit of infrastructure definition written in a programming language (TypeScript, Python, Java, C#, Go). Constructs compose other constructs; the CDK generates a CloudFormation template from them when you run cdk synth. The mechanism’s value is that it gives developers composition, “our standard service” becomes an L3 construct, imported as a package, instantiated with three lines.

The difference: custom resources extend CloudFormation’s runtime capabilities. Constructs extend CDK’s authoring capabilities. They solve different problems; they can coexist in the same stack.

Two related mechanisms worth knowing and separating:

  • AWS CloudFormation modules, a way to package reusable templates as CloudFormation registry entries, usable from any template. A convergence with CDK’s construct idea, from the CloudFormation side.
  • CloudFormation resource providers (third-party resource types), a way to extend CloudFormation’s native resource catalogue with a proper Type::Namespace::Resource that behaves like an AWS resource. Third parties publish these (e.g. Datadog has Datadog::Monitors::Monitor). More structured than custom resources; higher bar to build.

Side by side

Mechanism Extends Written in Deploys as Good for
Custom resource (Lambda-backed) CloudFormation runtime Any Lambda language A resource in the template One-off or bespoke lifecycle hooks
CDK construct (L2/L3) CDK authoring TS / Py / Java / C# / Go Generated CloudFormation Reusable application shapes
CDK construct wrapping a custom resource Both Same Same A construct whose behaviour needs a custom resource
CloudFormation module CloudFormation authoring Registered template A new CloudFormation type Template-first teams wanting reuse
Third-party resource provider CloudFormation catalogue Any language (CloudFormation CLI SDK) Registered type Polished, reusable, cross-team resources

Reading the table by problem rather than by mechanism:

  • Datadog integration, extends CloudFormation’s runtime (it needs to call Datadog’s API during stack events). Custom resource, Lambda-backed. Optionally: use Datadog’s published third-party resource provider to get a cleaner Datadog::Monitors::Monitor type. Both work; the second is polished.
  • Standard service pattern, extends CDK’s authoring. An L3 construct is exactly the shape of this problem: new PaymentsStandardService(this, 'Receipts', {...}) expands into the Lambda, API, table, IAM, dashboards, and alarms. No runtime hook needed; the template that comes out is plain CloudFormation.
  • GitHub Actions workflow, extends CloudFormation’s runtime. A custom resource backed by a Lambda that calls GitHub’s API to commit a file. A CDK construct could wrap the custom resource so teams don’t think about the Lambda, but the custom resource is the thing doing the work.

The two mechanisms in depth

Custom resource anatomy. A resource in a template:

DatadogMonitor:
  Type: Custom::DatadogMonitor
  Properties:
    ServiceToken: !GetAtt MonitorProviderFunction.Arn
    Name: !Sub "${AWS::StackName}-p99-latency"
    Query: "avg:aws.lambda.duration.p99{functionname:receipts}"
    Threshold: 2000
    ApiKeySecretArn: !Ref DatadogApiKeySecret

The ServiceToken points at the Lambda function (or SNS topic). Everything else is input for the Lambda.

The Lambda’s event payload looks like:

{
  "RequestType": "Create",
  "ResponseURL": "https://...pre-signed S3 URL...",
  "StackId": "...",
  "RequestId": "...",
  "LogicalResourceId": "DatadogMonitor",
  "ResourceType": "Custom::DatadogMonitor",
  "ResourceProperties": {
    "Name": "...",
    "Query": "...",
    "Threshold": 2000,
    "ApiKeySecretArn": "..."
  }
}

The Lambda creates the Datadog monitor, then PUTs a response to the pre-signed URL:

{
  "Status": "SUCCESS",
  "PhysicalResourceId": "datadog-monitor-abc123",
  "StackId": "...",
  "RequestId": "...",
  "LogicalResourceId": "DatadogMonitor",
  "Data": { "MonitorId": "abc123" }
}

CloudFormation waits for this response (up to the timeout) before proceeding. Update events include the old properties; Delete events run when the stack is deleted or when the resource is replaced. The Lambda has to be idempotent – Create might be retried, Update might run on every stack update, Delete must succeed even if the underlying thing was already gone.

AWS provides helper libraries: the aws-cloudformation-response library for raw Lambda-backed custom resources, and cfn-lambda / custom-resource-helper for Python, all of which handle the signing, PUT, and error paths.

CDK custom resources specifically are a wrapper. cdk-custom-resources (or the CDK Provider framework) packages the Lambda, the SNS topic or direct invocation, and the response-signalling into a single construct. You write a Lambda handler that returns a result; the CDK Provider handles the CloudFormation signalling. Cleaner than hand-rolling.

CDK construct anatomy. An L3 construct:

export interface PaymentsStandardServiceProps {
  codePath: string;
  routes: Array<{ path: string, method: string }>;
  tableSchema: dynamodb.TableProps;
  ownership: { team: string, service: string };
}

export class PaymentsStandardService extends Construct {
  public readonly function: lambda.Function;
  public readonly table: dynamodb.Table;
  public readonly api: apigw.RestApi;

  constructor(scope: Construct, id: string, props: PaymentsStandardServiceProps) {
    super(scope, id);
    this.table = new dynamodb.Table(this, 'Table', props.tableSchema);
    this.function = new lambda.Function(this, 'Handler', {
      code: lambda.Code.fromAsset(props.codePath),
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      tracing: lambda.Tracing.ACTIVE,
      logRetention: logs.RetentionDays.ONE_MONTH,
    });
    this.table.grantReadWriteData(this.function);
    // tags, alarms, dashboards, X-Ray trace role, etc.
    this.api = new apigw.RestApi(this, 'Api', { /* ... */ });
    for (const r of props.routes) {
      this.api.root.resourceForPath(r.path).addMethod(r.method,
        new apigw.LambdaIntegration(this.function));
    }
  }
}

Services consume it:

new PaymentsStandardService(this, 'Receipts', {
  codePath: './src',
  routes: [{ path: '/receipts/{id}', method: 'GET' }],
  tableSchema: { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }},
  ownership: { team: 'payments', service: 'receipts' },
});

The generated CloudFormation template contains all the underlying resources; the template is plain and deployable. No custom resource, no Lambda-during-deploy, no runtime hook.

Choosing between them, visualised

Decision tree: what am I extending? Does it require a hook during Create / Update / Delete? YES Custom resource Lambda-backed or third-party resource provider runs during stack events, signals back to CFN NO Need a reusable authoring shape for many stacks? YES CDK L3 construct publish to internal package registry compiles to plain CloudFormation NO Plain CloudFormation or plain CDK stack Datadog monitor create / update / delete against Datadog API GitHub workflow file commits via GitHub API at deploy time PaymentsStandardService Lambda + API + Table + alarms + IAM three lines to instantiate They can combine A CDK construct can include a custom resource internally, so the service presents a construct API to consumers while still running Lambda-backed hooks during stack events.
Custom resources solve deploy-time extension; constructs solve authoring-time reuse. They compose.

The picks in depth

Datadog monitor → custom resource, ideally wrapped in a construct. A Lambda-backed custom resource with three handlers: create posts to POST /api/v1/monitor, update posts to PUT /api/v1/monitor/:id, delete posts to DELETE /api/v1/monitor/:id. The Lambda reads the Datadog API key from a Secrets Manager secret given to it by reference. The custom resource’s PhysicalResourceId is the Datadog monitor ID, so when CloudFormation deletes the stack it can call the right delete path.

The CDK wrapping makes this a single-line import:

new DatadogMonitor(this, 'Latency', {
  name: 'p99-latency',
  query: `avg:aws.lambda.duration.p99{functionname:${fn.functionName}}`,
  threshold: 2000,
});

Inside the construct: a cdk.custom_resources.Provider that packages the Lambda; the Lambda shared across monitors (the custom resource’s backing Lambda is a singleton per stack, invoked many times). Alternatively: Datadog’s third-party resource provider, installed once in the account, turns this into a native Datadog::Monitors::Monitor resource, even cleaner, no Lambda to manage.

Standard service pattern → CDK L3 construct. No custom resource needed; no deploy-time hook. The construct encapsulates CloudFormation-native resources and outputs a plain template. Publish to the team’s internal npm registry as @acme/cdk-patterns; every new service imports and instantiates. When the platform standard changes (next quarter: every function gets a SnapStart flag), update the construct, bump the version, every service picks it up on its next deploy.

GitHub workflow → custom resource. The workflow file is not an AWS resource; it lives in a GitHub repository. A custom resource with create/update/delete handlers calls GitHub’s Contents API to write .github/workflows/deploy.yml in the service’s repo on stack create and delete it on stack delete. The physical ID is the path in the repo; the delete handler tolerates a 404 (file already gone) because idempotency matters.

When to reach for a third-party resource provider instead

Writing a custom resource is fine for one-off things. Writing the same custom resource twice for the same external system signals it’s time for a resource provider. A resource provider registers a proper CloudFormation resource type (MyOrg::Datadog::Monitor) that behaves like any AWS resource: handled by the CloudFormation registry, cached types, real schema, proper drift detection. The bar to build is higher (implement the CloudFormation CLI contract), but for platforms serving many teams, it’s worth it.

A worked combination

A service that wants the standard pattern and a Datadog monitor:

export class Receipts extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);
    const svc = new PaymentsStandardService(this, 'Service', {
      codePath: './src',
      routes: [{ path: '/receipts/{id}', method: 'GET' }],
      tableSchema: { partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }},
      ownership: { team: 'payments', service: 'receipts' },
    });
    new DatadogMonitor(this, 'Latency', {
      name: `${id}-p99-latency`,
      query: `avg:aws.lambda.duration.p99{functionname:${svc.function.functionName}}`,
      threshold: 2000,
    });
  }
}

Two constructs, one stack. The standard-service construct produces plain CloudFormation; the Datadog-monitor construct produces a Lambda-backed custom resource. Both deploy as one cdk deploy.

What’s worth remembering

  1. Custom resources extend CloudFormation’s runtime. A Lambda that runs during Create / Update / Delete, signalling back with success and a physical ID.
  2. Constructs extend CDK’s authoring. Composable infrastructure pieces in a real programming language; compiled to CloudFormation.
  3. They are not alternatives. They solve different problems. A construct can contain a custom resource inside it.
  4. Custom resources must be idempotent. Create can be retried; Delete must tolerate “already gone”; Update sees old and new property bags.
  5. The PhysicalResourceId is how CloudFormation tracks the underlying thing. Change it during Update and CloudFormation treats it as a replacement (old physical gets a Delete).
  6. The CDK Provider framework is the clean way to package custom resources. It handles the signalling, the Lambda, and the lifecycle. Avoid hand-rolling in CDK.
  7. Third-party resource providers are the polished alternative. Register a type with the CloudFormation registry; get a real resource that behaves like AWS’s native ones. Higher build cost; worth it for cross-team use.
  8. CloudFormation modules are the module-system answer from the template side. Convergence with CDK constructs; a reusable template-plus-schema published to the registry.
  9. L1, L2, L3 is the CDK construct hierarchy. L1 mirrors CloudFormation; L2 adds defaults; L3 composes application shapes. Most platform-library constructs are L3.
  10. Match the tool to the job. Need a hook during deploy: custom resource. Need a reusable authoring shape: construct. Need both: a construct that wraps a custom resource.

Custom resources when CloudFormation needs to run logic during stack events; constructs when the team needs to reuse an authoring pattern. Two mechanisms, two extensions, cleanly separable. The work isn’t picking one and fitting everything to it, it’s reading the shape of each extension problem and using the mechanism that fits.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.