Skip to content
terraconstructs
Apache-2.0built on CDK TerrainTerraform + OpenTofu

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
The actual distinction

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, IGrantable and IPrincipalcan 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.

declarative vs. imperativedocument-oriented vs. object-oriented
Aspect
Document
Object
  • The unit
    A module — a directory of files.
    A construct — a class you can instantiate.
  • Its interface
    Exactly its variables and outputs. A shape, checked late.
    A TypeScript type. Checked before you ever run a plan.
  • Composition
    Nest a module, then re-declare every variable you want to pass through.
    Pass the object. It carries its own behaviour with it.
  • Reuse
    Copy 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 concerns
    Something 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 lives
    In the templating layer: locals, ternaries, try(), merge(), jsonencode().
    In methods, on the object that owns the invariant.
Guided tour

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.

Workshop — Lambda + API Gateway

L2 constructs, the asset pipeline, and the IAM wiring the framework derives for you.

Input · TypeScriptobjects
src/main.ts26 lines
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,
});
}
}
Output · HCL230 lines synthesized
cdktn.out/stacks/workshop/cdk.tf.hcl230 lines
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.

Where the object graph pays for itself

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.

src/main.ts
// whatever you construct here…
const target = new LambdaFunction(this, "Worker", { /* … */ })

// …this line never changes
table.grantReadWriteData(target)
synthesizes to2 addresses
cdk.tf.hcl
  1. 01
    aws_iam_role.Worker_ServiceRolecreated lazily

    execution role, materialized on first use — this is the identifier that crosses the interface

  2. 02
    aws_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.

What works today

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.

Ported in full

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")
Ported in full

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)
Ported in full

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)
Roadmap

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 progress
The stack underneath

No 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.

  1. 01

    TerraConstructs

    Apache-2.0

    L2 construct library — asset pipeline, IAM SDK, ASL, service constructs.

  2. 02

    CDK Terrain (cdktn)

    MPL-2.0

    The community fork of the Terraform CDK. Synthesizes constructs and targets both OpenTofu and Terraform.

  3. 03

    terraform-provider-aws

    MPL-2.0

    Plain provider resources. Not a wrapper, not a shim — the same resource types your modules already declare.

  4. 04

    Terraform / OpenTofu

    BUSL / MPL-2.0

    The engine, the providers and the state you already operate.

Your existing gates just work

Linters and SAST
  • 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.

Automation and collaboration
  • 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.

Why this matters now

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.

least-privilege IAM policy, authored by reading the module
0/11 found|~0 min CI

Policy written by enumerating what the module “obviously” needs. Step through what a real deployment actually demanded.

Enumerated by hand or by agent

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  */
  ]
}
Derived from intent

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.