Infrastructure as data
is not the same as code.
TerraConstructs is an open-source L2 construct library for Terraform and OpenTofu. It gives you the AWS CDK programming model — the asset pipeline, the IAM grant SDK, Step Functions ASL — on top of the providers and state you already run.
- 26
- lines of TypeScript
- 230
- lines of HCL, synthesized
- 0
- IAM statements hand-written
It was never declarative versus imperative.
HCL and a CDK program are both declarative. Both describe a desired end state; both hand that description to an engine that figures out how to get there. A for loop that builds a list of subnets is no less declarative than a for_each that does the same thing.
The real split is how infrastructure is modelled: as a document to be merged, looked up and templated — or as typed objects with behaviour.
A Terraform module's interface is exactly its variables and outputs — it's a document, not a type. Two resources can't hand each other permission to talk; something has to sit outside both and wire up the rule.
In an object-oriented model, that wiring is the object graph. A construct that implements a capability interface — TerraConstructs’ port of AWS CDK’s
IConnectable,IGrantableandIPrincipal— can be handed to any other construct's connection or grant method, regardless of what either one actually is underneath.That's polymorphism, and a document has no place to put it.
- The unitA module — a directory of files.A construct — a class you can instantiate.
- Its interfaceExactly its variables and outputs. A shape, checked late.A TypeScript type. Checked before you ever run a plan.
- CompositionNest a module, then re-declare every variable you want to pass through.Pass the object. It carries its own behaviour with it.
- ReuseCopy the block, change the strings. Or a for_each and a lookup map.Subclass it, or wrap it in a construct that means something to your team.
- Cross-cutting concernsSomething outside both resources must know about both and wire the rule.One asks the other for a grant. Neither needs to know what the other is.
- Where logic livesIn the templating layer: locals, ternaries, try(), merge(), jsonencode().In methods, on the object that owns the invariant.
Read the input. Then read what it became.
Nothing here is a mock-up. The HCL on the right is the real synthesized output of the TypeScript on the left, pre-compiled at build time. Step through it and watch which lines each construct is responsible for.
L2 constructs, the asset pipeline, and the IAM wiring the framework derives for you.
import { Construct } from "constructs";import { AwsStack, AwsStackProps } from "terraconstructs/lib/aws";import { Code, LambdaFunction, Runtime, LambdaRestApi,} from "terraconstructs/lib/aws/compute";
export class CdkWorkshopStack extends AwsStack { constructor(scope: Construct, id: string, props: AwsStackProps) { super(scope, id, props);
const hello = new LambdaFunction(this, "HelloHandler", { runtime: Runtime.NODEJS_22_X, code: Code.fromAsset("lambda"), handler: "hello.handler", });
new LambdaRestApi(this, "Endpoint", { handler: hello, registerOutputs: true, cloudWatchRole: false, }); }}terraform { required_providers { aws = { version = "5.100.0" source = "aws" } }}
provider "aws" { region = "us-east-1"}
data "aws_caller_identity" "CallerIdentity" { provider = "aws"}data "aws_partition" "Partitition" { provider = "aws"}data "aws_iam_policy_document" "HelloHandler_ServiceRole_AssumeRolePolicy_7561E865" { statement { actions = [ "sts:AssumeRole", ] effect = "Allow" principals = [ { identifiers = [ "${data.aws_service_principal.aws_svcp_default_region_lambda.name}", ] type = "Service" }, ] }}resource "aws_iam_role" "HelloHandler_ServiceRole_11EF7C63" { assume_role_policy = data.aws_iam_policy_document.HelloHandler_ServiceRole_AssumeRolePolicy_7561E865.json managed_policy_arns = [ "arn:${data.aws_partition.Partitition.partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" ] name_prefix = "demo-uuid-HelloHandlerServiceRole" tags = { "grid:EnvironmentName" = "demo" "grid:UUID" = "demo-uuid" Name = "demo-HelloHandler" }}data "aws_iam_policy_document" "HelloHandler_ServiceRole_DefaultPolicy_8633F352" { statement { actions = [ "xray:PutTraceSegments", "xray:PutTelemetryRecords", ] effect = "Allow" resources = [ "*", ] }}resource "aws_iam_role_policy" "HelloHandler_ServiceRole_DefaultPolicy_ResourceRoles0_5982DDFD" { name = "HelloHandlerServiceRoleDefaultPolicyB2AAA7FD" policy = data.aws_iam_policy_document.HelloHandler_ServiceRole_DefaultPolicy_8633F352.json role = aws_iam_role.HelloHandler_ServiceRole_11EF7C63.name}resource "aws_cloudwatch_log_group" "HelloHandler_LogGroup_49850324" { name = "/aws/lambda/demo-uuid-HelloHandler" retention_in_days = 7 tags = { "grid:EnvironmentName" = "demo" "grid:UUID" = "demo-uuid" Name = "demo-HelloHandler" }}resource "aws_lambda_function" "HelloHandler_2E4FBA4D" { architectures = [ "x86_64" ] function_name = "demo-uuid-HelloHandler" handler = "hello.handler" memory_size = 128 role = aws_iam_role.HelloHandler_ServiceRole_11EF7C63.arn runtime = "nodejs22.x" s3_bucket = aws_s3_bucket.AssetBucket.bucket s3_key = aws_s3_object.FileAsset_S3.key tags = { "grid:EnvironmentName" = "demo" "grid:UUID" = "demo-uuid" Name = "demo-HelloHandler" } timeout = 3 environment { variables = {
} } tracing_config { mode = "Active" } depends_on = [ "aws_cloudwatch_log_group.HelloHandler_LogGroup_49850324", "data.aws_iam_policy_document.HelloHandler_ServiceRole_AssumeRolePolicy_7561E865", "aws_iam_role.HelloHandler_ServiceRole_11EF7C63", "data.aws_iam_policy_document.HelloHandler_ServiceRole_DefaultPolicy_8633F352", "data.aws_iam_policy_document.HelloHandler_ServiceRole_AssumeRolePolicy_7561E865", "aws_iam_role.HelloHandler_ServiceRole_11EF7C63", "data.aws_iam_policy_document.HelloHandler_ServiceRole_DefaultPolicy_8633F352", ]}resource "aws_s3_bucket" "AssetBucket" { bucket = "demo-uuid-${data.aws_caller_identity.CallerIdentity.account_id}-us-east-1"}resource "aws_s3_object" "FileAsset_S3" { bucket = aws_s3_bucket.AssetBucket.bucket key = "ac628f13c88c233682e5ac4270a6063b0f3ef8dc2a12061e9d762bad947a292e.zip" source = "assets/FileAsset/ac628f13c88c233682e5ac4270a6063b0f3ef8dc2a12061e9d762bad947a292e/archive.zip" source_hash = "ac628f13c88c233682e5ac4270a6063b0f3ef8dc2a12061e9d762bad947a292e"}resource "aws_api_gateway_rest_api" "Endpoint_EEF1FD8F" { name = "Endpoint" policy = { isBlock = false type = "simple" storageClassType = "string" } tags = { "grid:EnvironmentName" = "demo" "grid:UUID" = "demo-uuid" Name = "demo-Endpoint" }}resource "aws_api_gateway_deployment" "Endpoint_Deployment_318525DA" { description = "Automatically created by the RestApi construct" rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id depends_on = [ "aws_api_gateway_resource.Endpoint_proxy_39E2174E", "aws_api_gateway_method.Endpoint_ANY_485C938B", "aws_api_gateway_method.Endpoint_proxy_ANY_C09721C5", "aws_api_gateway_resource.Endpoint_proxy_39E2174E", "aws_api_gateway_method.Endpoint_ANY_485C938B", "aws_api_gateway_method.Endpoint_proxy_ANY_C09721C5", "aws_api_gateway_integration.Endpoint_proxy_ANY_Integration_3EA10C81", "aws_api_gateway_integration.Endpoint_ANY_Integration_21F74837", ] lifecycle { create_before_destroy = true } triggers = { redeployment = "21b9f7b3fb6c94640a9065f699fde4c6" }}resource "aws_api_gateway_stage" "Endpoint_DeploymentStageprod_B78BEEA0" { deployment_id = aws_api_gateway_deployment.Endpoint_Deployment_318525DA.id rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id stage_name = "prod" tags = { "grid:EnvironmentName" = "demo" "grid:UUID" = "demo-uuid" Name = "demo-Endpoint" }}resource "aws_api_gateway_resource" "Endpoint_proxy_39E2174E" { parent_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.root_resource_id path_part = "{proxy+}" rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}resource "aws_lambda_permission" "Endpoint_proxy_ANY_ApiPermissionEndpointANYproxy_5077F498" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.HelloHandler_2E4FBA4D.arn principal = "apigateway.amazonaws.com" source_arn = "arn:${data.aws_partition.Partitition.partition}:execute-api:us-east-1:${data.aws_caller_identity.CallerIdentity.account_id}:${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}/${aws_api_gateway_stage.Endpoint_DeploymentStageprod_B78BEEA0.stage_name}/*/*"}resource "aws_lambda_permission" "Endpoint_proxy_ANY_ApiPermissionTestEndpointANYproxy_53B9BBE7" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.HelloHandler_2E4FBA4D.arn principal = "apigateway.amazonaws.com" source_arn = "arn:${data.aws_partition.Partitition.partition}:execute-api:us-east-1:${data.aws_caller_identity.CallerIdentity.account_id}:${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}/test-invoke-stage/*/*"}resource "aws_api_gateway_method" "Endpoint_proxy_ANY_C09721C5" { authorization = "NONE" http_method = "ANY" resource_id = aws_api_gateway_resource.Endpoint_proxy_39E2174E.id rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}resource "aws_api_gateway_integration" "Endpoint_proxy_ANY_Integration_3EA10C81" { http_method = aws_api_gateway_method.Endpoint_proxy_ANY_C09721C5.http_method integration_http_method = "POST" resource_id = aws_api_gateway_resource.Endpoint_proxy_39E2174E.id rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id type = "AWS_PROXY" uri = "arn:${data.aws_partition.Partitition.partition}:apigateway:us-east-1:lambda:path/2015-03-31/functions/${aws_lambda_function.HelloHandler_2E4FBA4D.arn}/invocations"}resource "aws_lambda_permission" "Endpoint_ANY_ApiPermissionEndpointANY_0F391E45" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.HelloHandler_2E4FBA4D.arn principal = "apigateway.amazonaws.com" source_arn = "arn:${data.aws_partition.Partitition.partition}:execute-api:us-east-1:${data.aws_caller_identity.CallerIdentity.account_id}:${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}/${aws_api_gateway_stage.Endpoint_DeploymentStageprod_B78BEEA0.stage_name}/*/"}resource "aws_lambda_permission" "Endpoint_ANY_ApiPermissionTestEndpointANY_A9F55FBA" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.HelloHandler_2E4FBA4D.arn principal = "apigateway.amazonaws.com" source_arn = "arn:${data.aws_partition.Partitition.partition}:execute-api:us-east-1:${data.aws_caller_identity.CallerIdentity.account_id}:${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}/test-invoke-stage/*/"}resource "aws_api_gateway_method" "Endpoint_ANY_485C938B" { authorization = "NONE" http_method = "ANY" resource_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.root_resource_id rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}resource "aws_api_gateway_integration" "Endpoint_ANY_Integration_21F74837" { http_method = aws_api_gateway_method.Endpoint_ANY_485C938B.http_method integration_http_method = "POST" resource_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.root_resource_id rest_api_id = aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id type = "AWS_PROXY" uri = "arn:${data.aws_partition.Partitition.partition}:apigateway:us-east-1:lambda:path/2015-03-31/functions/${aws_lambda_function.HelloHandler_2E4FBA4D.arn}/invocations"}
output "EndpointOutputs" { value = { restApiId = "${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}" restApiName = "Endpoint" restApiRootResourceId = "${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.root_resource_id}" url = "https://${aws_api_gateway_rest_api.Endpoint_EEF1FD8F.id}.execute-api.us-east-1.${data.aws_partition.Partitition.dns_suffix}/${aws_api_gateway_stage.Endpoint_DeploymentStageprod_B78BEEA0.stage_name}/" } description = "Outputs for demo-Endpoint"}data "aws_service_principal" "aws_svcp_default_region_lambda" { service_name = "lambda"}Both files above are real: 26 lines of TypeScript on the left, the 230 lines of HCL cdktn synth produced from them on the right. Highlighted regions are clickable, or step through them in order.
One call site. Whatever it takes underneath.
table.grantReadWriteData(target)
It accepts an IGrantable and asks it for a principal. What it takes to produce that principal is entirely the implementation’s business — sometimes one role, sometimes three chained resources, sometimes nothing at all.
// whatever you construct here…
const target = new LambdaFunction(this, "Worker", { /* … */ })
// …this line never changes
table.grantReadWriteData(target)- 01aws_iam_role.Worker_ServiceRolecreated lazily
execution role, materialized on first use — this is the identifier that crosses the interface
- 02aws_iam_role_policy.Worker_ServiceRole_Defaultwired up
the statement this grant contributed
Note how little the call site knows. It never named a role, a profile, a security group or a rule — and for ArnPrincipal there was no resource to name at all. In a document model each of these is a separate wiring problem, solved outside both parties. Here it is one method, resolved by the graph.
Not a thin wrapper. The parts of CDK that were hard to build.
Anyone can generate HCL from a template. These are the subsystems that make the programming model actually feel different — each one ported and in use.
The AWS CDK asset pipeline
Code.fromAsset() and DockerImageAsset work the way you remember them — except the bundling, hashing, upload and lifecycle are handled by Terraform providers and Terraform state. Bundle a TypeScript Lambda handler and ship it via S3, or build and publish a container to ECR, without writing any of the glue.
code: Code.fromAsset("lambda")The IAM grant SDK
IPrincipal, IGrantable and IGrant, implemented on top of terraform-provider-aws IAM resources. Cross-service policy is generated from the object graph: a queue can grant a function permission to consume it, and neither construct needs to know the other’s type.
table.grantReadWriteData(handler)Step Functions ASL
Compose state machine fragments as objects instead of hand-editing a large JSON DSL. Because fragments are IGrantable-aware, the IAM actions each task needs bubble up and aggregate onto the state machine role automatically.
definition: submit.next(waitFor).next(publish)Beyond AWS
The construct model is provider-agnostic; the current library covers the ported AWS CDK surface. L2 constructs for GCP and Azure are the next milestone — and the contribution path is open.
// terraconstructs/lib/gcp — in progressNo new engine. No new state format. No new vendor.
The output is plain terraform-provider-aws — the same resource types your modules already declare. Nobody has to sign a new contract, stand up a new control plane, or learn to lint and operate CloudFormation.
Your platform team keeps the engine, the state, the providers and the entire toolchain they have been running for years. TerraConstructs sits above all of it and hands them HCL.
- 01
TerraConstructs
Apache-2.0L2 construct library — asset pipeline, IAM SDK, ASL, service constructs.
- 02
CDK Terrain (cdktn)
MPL-2.0The community fork of the Terraform CDK. Synthesizes constructs and targets both OpenTofu and Terraform.
- 03
terraform-provider-aws
MPL-2.0Plain provider resources. Not a wrapper, not a shim — the same resource types your modules already declare.
- 04
Terraform / OpenTofu
BUSL / MPL-2.0The engine, the providers and the state you already operate.
Your existing gates just work
- tflint
- Checkov
- OPA / Conftest
- Terrascan
- Trivy
- Sentinel
They read HCL and provider resources, which is exactly what synthesis emits. Every OPA bundle your security team has accumulated keeps evaluating the same resource addresses.
- Atlantis
- Terraform Cloud
- env0
- Scalr
- Spacelift
- Terrateam
- Digger
None of them need to know TerraConstructs exists. They see plain Terraform — or OpenTofu — and plan and apply it the way they always have.
And one thing you could not do before
Policy-as-code runs after the document exists: you emit HCL, then a scanner tells you it was wrong. Because there is now an object graph, platform teams get a hook that runs before synthesis.
// visit every node, before any HCL is written
Aspects.of(app).add(
new EncryptionAtRest(),
new RequireTagsAspect(taxonomy),
)An Aspect visits every construct and can mutate it — turning on encryption, attaching a tag taxonomy, forcing a log retention floor. You stop failing builds for drift from a standard and start applying the standard.
Hardening becomes a library your teams inherit, not a wiki page they are audited against. And your scanners still run afterwards, unchanged, as the backstop.
The cheapest tokens are the ones you never spend.
The constraint on adopting AI in 2026 is not access to frontier models — it is what they cost to run. Plenty of organisations spent an annual budget in six months and are now re-forecasting. So the question stops being “can an agent do this?” and becomes “how much iteration does this task require before it converges?”
An agent can absolutely author raw provider configuration for every resource. It will just have to validate its way there.
Policy written by enumerating what the module “obviously” needs.
Step through what a real deployment actually demanded.
Every action, every resource ARN, every condition key — transcribed, then corrected against reality one denial at a time. Nothing about the policy states why any of it is there.
{
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"kms:Decrypt",
"kms:GenerateDataKey",
/* … 25 more, some invalid, */
/* one wildcard too broad */
]
}The constructs already know they are encrypted, already know their own ARNs. The agent picks an intent; the library owns the transcription — and the KMS grant nobody remembered comes along with it.
const state = new Bucket(this, "TfMigrate", {
encryption: BucketEncryption.KMS,
});
const config = new StringParameter(this, "Cfg", {
secure: true,
});
state.grantReadWrite(deployRole);
config.grantRead(deployRole);The loop is the line item
A model that cannot know an action exists has to discover it by failing. Every discovery is a full apply, a parsed error, a re-plan and a re-validate — context re-read from scratch each time. That is where the budget goes, and it buys knowledge thousands of teams have already paid for.
Intent-oriented, not resource-oriented
grantReadWrite is a decision. Thirty-one policy actions across four services is a transcription. Models are markedly more reliable choosing among a few well-typed intents than emitting hundreds of interdependent low-level fields, and a typed surface makes an invalid action a compile error rather than a silent no-op.
Deterministic and already tested
The constructs are end-to-end tested and synthesis is deterministic: the same program yields the same HCL, so an agent’s output is reviewable as a diff. The library carries the AWS trivia — which actions ignore resource scoping, which APIs tag on create — so nobody rediscovers it per repository.
Spend the context window on the problem nobody has solved yet — not on re-validating what every other infrastructure team has already re-validated, one denial at a time.
There is no paid tier. There is a community.
TerraConstructs is Apache-2.0 with no commercial offering and no enterprise edition. If it is useful to you, the way to repay it is to ask good questions in public and send patches.
Get help on cdk.dev
The CDK community Slack is where questions get answered — including the TerraConstructs channels. There is no support contract to buy, because there is nothing to sell.
Join cdk.devWork through the AWS workshop
A guided, hands-on build: Lambda, API Gateway, assets and IAM, deployed with Terraform or OpenTofu from a construct program.
Open the workshopContribute a construct
The AWS surface is ported; GCP and Azure L2s are next. Good first issues, an open roadmap, and Apache-2.0 all the way down.
terraconstructs/base