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:
- Timeout (5 minutes) – Prevents long-lived functions from hanging stack deployments. Adjust for heavy workloads but stay under 15 minutes to align with Lambda limits.
- Latest AWS SDK – Ensures access to the newest API features, as advised by the CloudFormation service team.
- Log retention (7 days) – Gives on-call engineers enough context for incident response while controlling costs.
- Auto-generated IAM policy – Applies the principle of least privilege by inspecting the SDK calls you declare.
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
- Idempotency: Ensure your onCreate operations are idempotent
- Timeouts: Set appropriate timeouts for long-running operations
- Error Handling: AWS SDK operations in Custom Resources will fail the CloudFormation deployment on error
- Physical Resource IDs: Use unique, descriptive IDs for tracking
- Cleanup: Always implement onDelete for resources that need cleanup
- 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:
- Full access to all AWS service APIs
- Type-safe resource construction
- Automatic IAM policy generation
- CloudWatch Logs integration
- Lambda-backed implementation under the hood
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
<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>
<summary> Creates a new Custom Resource builder for deployment-time tasks. Example: customResource "db-seeder" { onCreate (CustomResourceHelpers.dynamoDBPutItem "MyTable" seedData) } </summary>
<summary> Helper functions for Custom Resource operations </summary>
<summary> Creates an SDK call for S3 operations </summary>
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>
val string: value: 'T -> string
--------------------
type string = System.String
<summary> Creates an SDK call for DynamoDB operations </summary>
<summary> Creates an SDK call for Secrets Manager </summary>
<summary> Creates an SDK call for SSM Parameter Store </summary>
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<string, object> { { "Name", "my-parameter" }, { "WithDecryption", true } }, PhysicalResourceId = PhysicalResourceId.FromResponse("Parameter.ARN") }, Policy = AwsCustomResourcePolicy.FromSdkCalls(new SdkCallsPolicyOptions { Resources = AwsCustomResourcePolicy.ANY_RESOURCE }) });</code></example>
--------------------
AwsSdkCall() : AwsSdkCall
<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<string, string> { { "Effect", "Allow" }, { "Action", "sts:AssumeRole" }, { "Resource", crossAccountRoleArn } }) }) });</code></example>
<summary> Creates a generic AWS SDK call </summary>
<summary> Common timeout durations </summary>
<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<string, object> { { "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>
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
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
<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>
<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>
<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>
<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>
<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>
<summary>DynamoDB's Read/Write capacity modes.</summary>
[<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)
<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(format: string) : string
System.DateTime.ToString(provider: System.IFormatProvider) : string
System.DateTime.ToString(format: string, provider: System.IFormatProvider) : string
FsCDK