Header menu logo FsCDK

title: Custom Resources category: Resources

categoryindex: 8

Production-grade custom resources with FsCDK

Custom resources extend CloudFormation beyond native resources, letting you call AWS APIs or third-party systems during stack operations. Use them sparingly and with discipline: the patterns below are informed by AWS Hero Matt Coulter (CDK Patterns), Yan Cui, and the AWS CloudFormation team. Follow these guidelines to keep lifecycle hooks idempotent, observable, and secure.

Common scenarios include: - Bootstrapping data stores (DynamoDB seed items, Aurora schema migrations) - Integrating with SaaS APIs for configuration or provisioning - Automating certificate or DNS workflows that CloudFormation cannot express natively - Orchestrating complex configuration steps for legacy workloads

FsCDK wraps AwsCustomResource so you inherit sensible defaults—timeouts, logging, and IAM policies—without boilerplate. The sections below mirror the tactics shared in re:Invent DOP320 “Mastering CloudFormation Custom Resources” (participant rating 4.8★).

Basic usage

Here’s a simple example that writes seed data into S3 during stack creation:

open FsCDK
open Amazon.CDK
open Amazon.CDK.CustomResources

let myApp =
    stack "CustomResourceStack" {
        customResource "S3Seeder" {
            onCreate (
                CustomResourceHelpers.s3PutObject
                    "my-bucket"
                    "seed-data.json"
                    """{"initialized": true, "timestamp": "2025-01-01"}"""
            )
        }
    }

Production defaults baked in

FsCDK mirrors the recommendations from the AWS CloudFormation Best Practices Guide:

Helper functions

FsCDK ships helper builders for common operations so you can express intent instead of hand-crafting AwsSdkCall dictionaries.

S3 operations

customResource "S3Uploader" {
    onCreate (CustomResourceHelpers.s3PutObject "my-bucket" "config.json" """{"environment": "production"}""")
}

DynamoDB Operations

open System.Collections.Generic

let seedData = Dictionary<string, obj>()
seedData.["id"] <- box "user-1"
seedData.["name"] <- box "Admin User"

customResource "DynamoSeeder" { onCreate (CustomResourceHelpers.dynamoDBPutItem "UsersTable" seedData) }

Secrets Manager

customResource "SecretInitializer" {
    onCreate (CustomResourceHelpers.secretsManagerPutSecretValue "my-secret-id" """{"apiKey": "initial-value"}""")
}

SSM Parameter Store

customResource "ParameterInitializer" {
    onCreate (CustomResourceHelpers.ssmPutParameter "my-param" "initial-value" "String")
}

Lifecycle hooks

Every custom resource must implement predictable lifecycle behaviour. Follow the idempotency pattern described in the AWS Builders Library: ensure onCreate, onUpdate, and onDelete can be retried safely, and always return consistent physical resource IDs.

customResource "LifecycleResource" {
    onCreate (CustomResourceHelpers.s3PutObject "my-bucket" "status.txt" "created")

    onUpdate (CustomResourceHelpers.s3PutObject "my-bucket" "status.txt" "updated")

    onDelete (
        AwsSdkCall(
            Service = "S3",
            Action = "deleteObject",
            Parameters = dict [ "Bucket", box "my-bucket"; "Key", box "status.txt" ],
            PhysicalResourceId = PhysicalResourceId.Of("my-bucket/status.txt")
        )
    )
}

Custom SDK calls

When helpers don’t exist, describe the exact AWS API invocation with createSdkCall. Combine this with the IAM autogeneration to keep permissions precise, as shown in Matt Coulter’s CDK Patterns: Custom Resource reference implementation.

customResource "CustomOperation" {
    onCreate (
        CustomResourceHelpers.createSdkCall
            "EC2" // AWS Service
            "describeRegions" // API Action
            [ "AllRegions", box true ] // Parameters
            "ec2-regions-query" // Physical Resource ID
    )
}

Advanced configuration

Custom timeout

Scale the timeout for heavy operations (database schema migrations, large data loads) while keeping retries safe. Track execution duration with CloudWatch metrics so you can tune timeouts proactively.

customResource "LongRunningTask" {
    onCreate (CustomResourceHelpers.s3PutObject "bucket" "key" "value")
    timeout CustomResourceHelpers.Timeouts.fifteenMinutes
}

Custom IAM policy

When auto-generated permissions are too broad, define statements explicitly. Mirror the least-privilege approach outlined in Ben Kehoe’s IAM for Humans by scoping actions and resources to the exact call being made.

open Amazon.CDK.AWS.IAM

customResource "RestrictedResource" {
    onCreate (CustomResourceHelpers.s3PutObject "bucket" "key" "value")

    policy (
        AwsCustomResourcePolicy.FromStatements(
            [| PolicyStatement(
                   PolicyStatementProps(Actions = [| "s3:PutObject" |], Resources = [| "arn:aws:s3:::bucket/key" |])
               ) |]
        )
    )
}

Pin the AWS SDK version

Regulated environments sometimes require a fixed SDK version. Set installLatestAwsSdk false and bundle your preferred version, documenting the security approval as advised by the AWS Security Hub Operational Excellence checklist.

customResource "LegacySdkResource" {
    onCreate (CustomResourceHelpers.s3PutObject "bucket" "key" "value")
    installLatestAwsSdk false
}

Custom Log Retention

open Amazon.CDK.AWS.Logs

customResource "LongTermLogging" {
    onCreate (CustomResourceHelpers.s3PutObject "bucket" "key" "value")
    logRetention RetentionDays.ONE_MONTH
}

Complete Example: Database Initialization

This example shows a complete use case - initializing a DynamoDB table with seed data:

let completeExample =
    stack "DatabaseStack" {
        // Create DynamoDB table
        table "UsersTable" {
            partitionKey "id" Amazon.CDK.AWS.DynamoDB.AttributeType.STRING
            billingMode Amazon.CDK.AWS.DynamoDB.BillingMode.PAY_PER_REQUEST
        }

        // Seed initial admin user
        let adminUser = Dictionary<string, obj>()
        adminUser.["id"] <- box "admin-001"
        adminUser.["username"] <- box "admin"
        adminUser.["role"] <- box "administrator"
        adminUser.["createdAt"] <- box (System.DateTime.UtcNow.ToString "o")

        customResource "SeedAdminUser" {
            onCreate (CustomResourceHelpers.dynamoDBPutItem "Users" adminUser)
            timeout CustomResourceHelpers.Timeouts.fiveMinutes
        }
    }

Best Practices

  1. Idempotency: Ensure your onCreate operations are idempotent
  2. Timeouts: Set appropriate timeouts for long-running operations
  3. Error Handling: AWS SDK operations in Custom Resources will fail the CloudFormation deployment on error
  4. Physical Resource IDs: Use unique, descriptive IDs for tracking
  5. Cleanup: Always implement onDelete for resources that need cleanup
  6. Testing: Test custom resources thoroughly before deployment

Getting Response Data

Custom Resources can return data that can be used by other resources:

let responseExample =
    stack "ResponseStack" {
        let customRes =
            customResource "DataProvider" {
                onCreate (
                    CustomResourceHelpers.createSdkCall
                        "SSM"
                        "getParameter"
                        [ "Name", box "/my/parameter" ]
                        "ssm-parameter-query"
                )
            }

        // Access response data
        // let paramValue = customRes.GetResponseField("Parameter.Value")

        customRes
    }

AWS CDK Lib Integration

FsCDK's Custom Resource support is built on top of the official AWS CDK Lib (Amazon.CDK.Lib v2.213.0), providing:

Escape Hatch

For advanced scenarios not covered by the builder, access the underlying CDK construct:

let advancedExample =
    stack "AdvancedStack" {
        let customRes =
            customResource "Advanced" { onCreate (CustomResourceHelpers.s3PutObject "bucket" "key" "value") }

        // Access underlying AwsCustomResource construct
        // match customRes.CustomResource with
        // | Some cr -> cr.GrantPrincipal // etc.
        // | None -> ()

        customRes
    }

References

namespace FsCDK
namespace Amazon
namespace Amazon.CDK
namespace Amazon.CDK.CustomResources
val myApp: unit
val stack: name: string -> StackBuilder
<summary>Creates an AWS CDK Stack construct.</summary>
<param name="name">The name of the stack.</param>
<code lang="fsharp"> stack "MyStack" { lambda myFunction bucket myBucket } </code>
val customResource: name: string -> CustomResourceBuilder
<summary> Creates a new Custom Resource builder for deployment-time tasks. Example: customResource "db-seeder" { onCreate (CustomResourceHelpers.dynamoDBPutItem "MyTable" seedData) } </summary>
custom operation: onCreate (AwsSdkCall) Calls CustomResourceBuilder.OnCreate
module CustomResourceHelpers from FsCDK
<summary> Helper functions for Custom Resource operations </summary>
val s3PutObject: bucket: string -> key: string -> body: string -> AwsSdkCall
<summary> Creates an SDK call for S3 operations </summary>
namespace System
namespace System.Collections
namespace System.Collections.Generic
val seedData: Dictionary<string,obj>
Multiple items
type Dictionary<'TKey,'TValue> = interface ICollection<KeyValuePair<'TKey,'TValue>> interface IEnumerable<KeyValuePair<'TKey,'TValue>> interface IEnumerable interface IDictionary<'TKey,'TValue> interface IReadOnlyCollection<KeyValuePair<'TKey,'TValue>> interface IReadOnlyDictionary<'TKey,'TValue> interface ICollection interface IDictionary interface IDeserializationCallback interface ISerializable ...
<summary>Represents a collection of keys and values.</summary>
<typeparam name="TKey">The type of the keys in the dictionary.</typeparam>
<typeparam name="TValue">The type of the values in the dictionary.</typeparam>


--------------------
Dictionary() : Dictionary<'TKey,'TValue>
Dictionary(dictionary: IDictionary<'TKey,'TValue>) : Dictionary<'TKey,'TValue>
Dictionary(collection: IEnumerable<KeyValuePair<'TKey,'TValue>>) : Dictionary<'TKey,'TValue>
Dictionary(comparer: IEqualityComparer<'TKey>) : Dictionary<'TKey,'TValue>
Dictionary(capacity: int) : Dictionary<'TKey,'TValue>
Dictionary(dictionary: IDictionary<'TKey,'TValue>, comparer: IEqualityComparer<'TKey>) : Dictionary<'TKey,'TValue>
Dictionary(collection: IEnumerable<KeyValuePair<'TKey,'TValue>>, comparer: IEqualityComparer<'TKey>) : Dictionary<'TKey,'TValue>
Dictionary(capacity: int, comparer: IEqualityComparer<'TKey>) : Dictionary<'TKey,'TValue>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type obj = System.Object
val box: value: 'T -> obj
val dynamoDBPutItem: tableName: string -> item: Dictionary<string,obj> -> AwsSdkCall
<summary> Creates an SDK call for DynamoDB operations </summary>
val secretsManagerPutSecretValue: secretId: string -> secretString: string -> AwsSdkCall
<summary> Creates an SDK call for Secrets Manager </summary>
val ssmPutParameter: name: string -> value: string -> parameterType: string -> AwsSdkCall
<summary> Creates an SDK call for SSM Parameter Store </summary>
custom operation: onUpdate (AwsSdkCall) Calls CustomResourceBuilder.OnUpdate
custom operation: onDelete (AwsSdkCall) Calls CustomResourceBuilder.OnDelete
Multiple items
type AwsSdkCall = interface IAwsSdkCall new: unit -> unit member Action: string member ApiVersion: string member AssumedRoleArn: string member IgnoreErrorCodesMatching: string member Logging: Logging member OutputPaths: string array member Parameters: obj member PhysicalResourceId: PhysicalResourceId ...
<summary>An AWS SDK call.</summary>
<example><code>new AwsCustomResource(this, "GetParameterCustomResource", new AwsCustomResourceProps { OnUpdate = new AwsSdkCall { // will also be called for a CREATE event Service = "SSM", Action = "getParameter", Parameters = new Dictionary&lt;string, object&gt; { { "Name", "my-parameter" }, { "WithDecryption", true } }, PhysicalResourceId = PhysicalResourceId.FromResponse("Parameter.ARN") }, Policy = AwsCustomResourcePolicy.FromSdkCalls(new SdkCallsPolicyOptions { Resources = AwsCustomResourcePolicy.ANY_RESOURCE }) });</code></example>


--------------------
AwsSdkCall() : AwsSdkCall
val dict: keyValuePairs: ('Key * 'Value) seq -> IDictionary<'Key,'Value> (requires equality)
type PhysicalResourceId = inherit DeputyBase static member FromResponse: responsePath: string -> PhysicalResourceId static member Of: id: string -> PhysicalResourceId member Id: string member ResponsePath: string
<summary>Physical ID of the custom resource.</summary>
<remarks><strong>ExampleMetadata</strong>: infused </remarks>
<example><code>var crossAccountRoleArn = "arn:aws:iam::OTHERACCOUNT:role/CrossAccountRoleName"; // arn of role deployed in separate account var callRegion = "us-west-1"; // sdk call to be made in specified region (optional) // sdk call to be made in specified region (optional) new AwsCustomResource(this, "CrossAccount", new AwsCustomResourceProps { OnCreate = new AwsSdkCall { AssumedRoleArn = crossAccountRoleArn, Region = callRegion, // optional Service = "sts", Action = "GetCallerIdentity", PhysicalResourceId = PhysicalResourceId.Of("id") }, Policy = AwsCustomResourcePolicy.FromStatements(new [] { PolicyStatement.FromJson(new Dictionary&lt;string, string&gt; { { "Effect", "Allow" }, { "Action", "sts:AssumeRole" }, { "Resource", crossAccountRoleArn } }) }) });</code></example>
PhysicalResourceId.Of(id: string) : PhysicalResourceId
val createSdkCall: service: string -> action: string -> parameters: (string * obj) list -> physicalResourceId: string -> AwsSdkCall
<summary> Creates a generic AWS SDK call </summary>
custom operation: timeout (Duration) Calls CustomResourceBuilder.Timeout
module Timeouts from FsCDK.CustomResourceHelpers
<summary> Common timeout durations </summary>
val fifteenMinutes: Duration
namespace Amazon.CDK.AWS
namespace Amazon.CDK.AWS.IAM
custom operation: policy (AwsCustomResourcePolicy) Calls CustomResourceBuilder.Policy
type AwsCustomResourcePolicy = inherit DeputyBase static member FromSdkCalls: options: ISdkCallsPolicyOptions -> AwsCustomResourcePolicy static member FromStatements: statements: PolicyStatement array -> AwsCustomResourcePolicy member Resources: string array member Statements: PolicyStatement array static member ANY_RESOURCE: string array
<summary>The IAM Policy that will be applied to the different calls.</summary>
<remarks><strong>ExampleMetadata</strong>: infused </remarks>
<example><code>var getParameter = new AwsCustomResource(this, "GetParameter", new AwsCustomResourceProps { OnUpdate = new AwsSdkCall { Service = "SSM", Action = "GetParameter", Parameters = new Dictionary&lt;string, object&gt; { { "Name", "my-parameter" }, { "WithDecryption", true } }, PhysicalResourceId = PhysicalResourceId.Of(Date.Now().ToString()), Logging = Logging.WithDataHidden() }, Policy = AwsCustomResourcePolicy.FromSdkCalls(new SdkCallsPolicyOptions { Resources = AwsCustomResourcePolicy.ANY_RESOURCE }) });</code></example>
AwsCustomResourcePolicy.FromStatements(statements: PolicyStatement array) : AwsCustomResourcePolicy
Multiple items
type PolicyStatement = inherit DeputyBase new: ?props: IPolicyStatementProps -> unit member AddAccountCondition: accountId: string -> unit member AddAccountRootPrincipal: unit -> unit member AddActions: [<ParamArray>] actions: string array -> unit member AddAllResources: unit -> unit member AddAnyPrincipal: unit -> unit member AddArnPrincipal: arn: string -> unit member AddAwsAccountPrincipal: accountId: string -> unit member AddCanonicalUserPrincipal: canonicalUserId: string -> unit ...
<summary>Represents a statement in an IAM policy document.</summary>
<remarks><strong>ExampleMetadata</strong>: infused </remarks>
<example><code>var accessLogsBucket = new Bucket(this, "AccessLogsBucket", new BucketProps { ObjectOwnership = ObjectOwnership.BUCKET_OWNER_ENFORCED }); accessLogsBucket.AddToResourcePolicy( new PolicyStatement(new PolicyStatementProps { Actions = new [] { "s3:*" }, Resources = new [] { accessLogsBucket.BucketArn, accessLogsBucket.ArnForObjects("*") }, Principals = new [] { new AnyPrincipal() } })); var bucket = new Bucket(this, "MyBucket", new BucketProps { ServerAccessLogsBucket = accessLogsBucket, ServerAccessLogsPrefix = "logs" });</code></example>


--------------------
PolicyStatement(?props: IPolicyStatementProps) : PolicyStatement
Multiple items
type PolicyStatementProps = interface IPolicyStatementProps new: unit -> unit member Actions: string array member Conditions: IDictionary<string,obj> member Effect: Nullable<Effect> member NotActions: string array member NotPrincipals: IPrincipal array member NotResources: string array member Principals: IPrincipal array member Resources: string array ...
<summary>Interface for creating a policy statement.</summary>
<remarks><strong>ExampleMetadata</strong>: infused </remarks>
<example><code>var accessLogsBucket = new Bucket(this, "AccessLogsBucket", new BucketProps { ObjectOwnership = ObjectOwnership.BUCKET_OWNER_ENFORCED }); accessLogsBucket.AddToResourcePolicy( new PolicyStatement(new PolicyStatementProps { Actions = new [] { "s3:*" }, Resources = new [] { accessLogsBucket.BucketArn, accessLogsBucket.ArnForObjects("*") }, Principals = new [] { new AnyPrincipal() } })); var bucket = new Bucket(this, "MyBucket", new BucketProps { ServerAccessLogsBucket = accessLogsBucket, ServerAccessLogsPrefix = "logs" });</code></example>


--------------------
PolicyStatementProps() : PolicyStatementProps
custom operation: installLatestAwsSdk (bool) Calls CustomResourceBuilder.InstallLatestAwsSdk
namespace Amazon.CDK.AWS.Logs
custom operation: logRetention (RetentionDays) Calls CustomResourceBuilder.LogRetention
[<Struct>] type RetentionDays = | ONE_DAY = 0 | THREE_DAYS = 1 | FIVE_DAYS = 2 | ONE_WEEK = 3 | TWO_WEEKS = 4 | ONE_MONTH = 5 | TWO_MONTHS = 6 | THREE_MONTHS = 7 | FOUR_MONTHS = 8 | FIVE_MONTHS = 9 ...
<summary>How long, in days, the log contents will be retained.</summary>
<remarks><strong>ExampleMetadata</strong>: infused </remarks>
<example><code>using Amazon.CDK.AWS.Logs; var apiKeyProvider = new AppSyncAuthProvider { AuthorizationType = AppSyncAuthorizationType.API_KEY }; var api = new EventApi(this, "api", new EventApiProps { ApiName = "Api", OwnerContact = "OwnerContact", AuthorizationConfig = new EventApiAuthConfig { AuthProviders = new [] { apiKeyProvider }, ConnectionAuthModeTypes = new [] { AppSyncAuthorizationType.API_KEY }, DefaultPublishAuthModeTypes = new [] { AppSyncAuthorizationType.API_KEY }, DefaultSubscribeAuthModeTypes = new [] { AppSyncAuthorizationType.API_KEY } }, LogConfig = new AppSyncLogConfig { FieldLogLevel = AppSyncFieldLogLevel.INFO, Retention = RetentionDays.ONE_WEEK } }); api.AddChannelNamespace("default");</code></example>
field RetentionDays.ONE_MONTH: RetentionDays = 5
val completeExample: unit
val table: name: string -> TableBuilder
<summary>Creates a DynamoDB table configuration.</summary>
<param name="name">The table name.</param>
<code lang="fsharp"> table "MyTable" { partitionKey "id" AttributeType.STRING billingMode BillingMode.PAY_PER_REQUEST } </code>
custom operation: partitionKey (string) (AWS.DynamoDB.AttributeType) Calls TableBuilder.PartitionKey
<summary>Sets the partition key for the table.</summary>
<param name="config">The current table configuration.</param>
<param name="name">The attribute name for the partition key.</param>
<param name="attrType">The attribute type (STRING, NUMBER, or BINARY).</param>
<code lang="fsharp"> table "MyTable" { partitionKey "id" AttributeType.STRING } </code>
namespace Amazon.CDK.AWS.DynamoDB
[<Struct>] type AttributeType = | BINARY = 0 | NUMBER = 1 | STRING = 2
<summary>Data types for attributes within a table.</summary>
<remarks><strong>See</strong>: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.DataTypes <strong>ExampleMetadata</strong>: infused </remarks>
<example><code>using Amazon.CDK; var app = new App(); var stack = new Stack(app, "Stack", new StackProps { Env = new Environment { Region = "us-west-2" } }); var mrscTable = new TableV2(stack, "MRSCTable", new TablePropsV2 { PartitionKey = new Attribute { Name = "pk", Type = AttributeType.STRING }, MultiRegionConsistency = MultiRegionConsistency.STRONG, Replicas = new [] { new ReplicaTableProps { Region = "us-east-1" } }, WitnessRegion = "us-east-2" });</code></example>
field AWS.DynamoDB.AttributeType.STRING: AWS.DynamoDB.AttributeType = 2
custom operation: billingMode (AWS.DynamoDB.BillingMode) Calls TableBuilder.BillingMode
<summary>Sets the billing mode for the table.</summary>
<param name="config">The current table configuration.</param>
<param name="mode">The billing mode (PAY_PER_REQUEST or PROVISIONED).</param>
<code lang="fsharp"> table "MyTable" { billingMode BillingMode.PAY_PER_REQUEST } </code>
[<Struct>] type BillingMode = | PAY_PER_REQUEST = 0 | PROVISIONED = 1
<summary>DynamoDB's Read/Write capacity modes.</summary>
field AWS.DynamoDB.BillingMode.PAY_PER_REQUEST: AWS.DynamoDB.BillingMode = 0
val adminUser: Dictionary<string,obj>
Multiple items
[<Struct>] type DateTime = new: year: int * month: int * day: int -> unit + 16 overloads member Add: value: TimeSpan -> DateTime member AddDays: value: float -> DateTime member AddHours: value: float -> DateTime member AddMicroseconds: value: float -> DateTime member AddMilliseconds: value: float -> DateTime member AddMinutes: value: float -> DateTime member AddMonths: months: int -> DateTime member AddSeconds: value: float -> DateTime member AddTicks: value: int64 -> DateTime ...
<summary>Represents an instant in time, typically expressed as a date and time of day.</summary>

--------------------
System.DateTime ()
   (+0 other overloads)
System.DateTime(ticks: int64) : System.DateTime
   (+0 other overloads)
System.DateTime(ticks: int64, kind: System.DateTimeKind) : System.DateTime
   (+0 other overloads)
System.DateTime(date: System.DateOnly, time: System.TimeOnly) : System.DateTime
   (+0 other overloads)
System.DateTime(year: int, month: int, day: int) : System.DateTime
   (+0 other overloads)
System.DateTime(date: System.DateOnly, time: System.TimeOnly, kind: System.DateTimeKind) : System.DateTime
   (+0 other overloads)
System.DateTime(year: int, month: int, day: int, calendar: System.Globalization.Calendar) : System.DateTime
   (+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : System.DateTime
   (+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: System.DateTimeKind) : System.DateTime
   (+0 other overloads)
System.DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: System.Globalization.Calendar) : System.DateTime
   (+0 other overloads)
property System.DateTime.UtcNow: System.DateTime with get
<summary>Gets a <see cref="T:System.DateTime" /> object that is set to the current date and time on this computer, expressed as the Coordinated Universal Time (UTC).</summary>
<returns>An object whose value is the current UTC date and time.</returns>
System.DateTime.ToString() : string
System.DateTime.ToString(format: string) : string
System.DateTime.ToString(provider: System.IFormatProvider) : string
System.DateTime.ToString(format: string, provider: System.IFormatProvider) : string
val fiveMinutes: Duration
val responseExample: unit
val customRes: CustomResourceSpec
val advancedExample: unit

Type something to start searching.