Python Terraform CDK: Cloud Infrastructure in Python
CDKTF (Cloud Development Kit for Terraform) lets you define Terraform infrastructure using Python, TypeScript, or other languages instead of HCL. The Python code synthesizes to Terraform JSON, then standard terraform apply deploys it. This means you get full Python expressiveness — loops, functions, classes, type hints, unit tests — while Terraform handles the actual provisioning, state management, and provider ecosystem. This guide builds a production AWS stack (VPC, ECS Fargate, RDS, ALB) using CDKTF Python.
Table of Contents
Installation and Project Setup
# Prerequisites: Node.js 18+, Terraform CLI, Python 3.10+
pip install cdktf cdktf-cdktf-provider-aws
# Initialize project
cdktf init --template=python --local
# Install AWS provider
pip install cdktf-cdktf-provider-aws
# Project structure:
# infra/
# main.py ← entry point
# stacks/
# network.py
# compute.py
# database.py
# constructs/
# fargate_service.py
# cdktf.json
// cdktf.json
{
"language": "python",
"app": "python3 main.py",
"terraformProviders": ["aws@~> 5.0"],
"terraformModules": [],
"sendCrashReports": false
}
Stacks and Constructs
A Stack is a deployable unit corresponding to one Terraform state file. A Construct is a reusable building block — analogous to a Python class that encapsulates related infrastructure. Constructs compose into stacks; stacks compose into apps.
# main.py
from cdktf import App, TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from constructs import Construct
from stacks.network import NetworkStack
from stacks.compute import ComputeStack
from stacks.database import DatabaseStack
class TechoralApp(App):
def __init__(self):
super().__init__()
env = "production"
region = "ap-south-1"
# Network stack (VPC, subnets, security groups)
network = NetworkStack(self, f"techoral-network-{env}",
env=env, region=region)
# Database stack depends on network
database = DatabaseStack(self, f"techoral-db-{env}",
env=env, region=region,
vpc_id=network.vpc_id,
subnet_ids=network.private_subnet_ids,
security_group_id=network.db_sg_id)
# Compute stack depends on network and database
ComputeStack(self, f"techoral-compute-{env}",
env=env, region=region,
vpc_id=network.vpc_id,
public_subnet_ids=network.public_subnet_ids,
private_subnet_ids=network.private_subnet_ids,
alb_sg_id=network.alb_sg_id,
ecs_sg_id=network.ecs_sg_id,
database_url=database.database_url)
if __name__ == "__main__":
TechoralApp().synth()
VPC and Networking
# stacks/network.py
from cdktf import TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.subnet import Subnet
from cdktf_cdktf_provider_aws.internet_gateway import InternetGateway
from cdktf_cdktf_provider_aws.nat_gateway import NatGateway
from cdktf_cdktf_provider_aws.eip import Eip
from cdktf_cdktf_provider_aws.route_table import RouteTable
from cdktf_cdktf_provider_aws.route_table_association import RouteTableAssociation
from cdktf_cdktf_provider_aws.route import Route
from cdktf_cdktf_provider_aws.security_group import SecurityGroup
from constructs import Construct
class NetworkStack(TerraformStack):
def __init__(self, scope: Construct, id: str, env: str, region: str):
super().__init__(scope, id)
AwsProvider(self, "aws", region=region)
# VPC
self._vpc = Vpc(self, "vpc",
cidr_block="10.0.0.0/16",
enable_dns_hostnames=True,
enable_dns_support=True,
tags={"Name": f"techoral-{env}", "Environment": env})
# Public subnets (ALB, NAT gateways)
azs = ["ap-south-1a", "ap-south-1b"]
self._public_subnets = [
Subnet(self, f"public-{i}",
vpc_id=self._vpc.id,
cidr_block=f"10.0.{i}.0/24",
availability_zone=azs[i],
map_public_ip_on_launch=True,
tags={"Name": f"techoral-public-{i}-{env}"})
for i in range(len(azs))
]
# Private subnets (ECS, RDS)
self._private_subnets = [
Subnet(self, f"private-{i}",
vpc_id=self._vpc.id,
cidr_block=f"10.0.{i+10}.0/24",
availability_zone=azs[i],
tags={"Name": f"techoral-private-{i}-{env}"})
for i in range(len(azs))
]
# Internet Gateway
igw = InternetGateway(self, "igw", vpc_id=self._vpc.id)
# NAT Gateway (one per AZ for HA)
eip = Eip(self, "nat-eip", domain="vpc")
nat = NatGateway(self, "nat",
subnet_id=self._public_subnets[0].id,
allocation_id=eip.id)
# Route tables
public_rt = RouteTable(self, "public-rt", vpc_id=self._vpc.id)
Route(self, "public-route", route_table_id=public_rt.id,
destination_cidr_block="0.0.0.0/0", gateway_id=igw.id)
for i, subnet in enumerate(self._public_subnets):
RouteTableAssociation(self, f"public-rta-{i}",
route_table_id=public_rt.id, subnet_id=subnet.id)
private_rt = RouteTable(self, "private-rt", vpc_id=self._vpc.id)
Route(self, "private-route", route_table_id=private_rt.id,
destination_cidr_block="0.0.0.0/0", nat_gateway_id=nat.id)
for i, subnet in enumerate(self._private_subnets):
RouteTableAssociation(self, f"private-rta-{i}",
route_table_id=private_rt.id, subnet_id=subnet.id)
# Security groups
self._alb_sg = SecurityGroup(self, "alb-sg",
vpc_id=self._vpc.id,
name=f"alb-sg-{env}",
ingress=[
{"from_port": 80, "to_port": 80, "protocol": "tcp", "cidr_blocks": ["0.0.0.0/0"]},
{"from_port": 443, "to_port": 443, "protocol": "tcp", "cidr_blocks": ["0.0.0.0/0"]},
],
egress=[{"from_port": 0, "to_port": 0, "protocol": "-1", "cidr_blocks": ["0.0.0.0/0"]}])
self._ecs_sg = SecurityGroup(self, "ecs-sg",
vpc_id=self._vpc.id,
name=f"ecs-sg-{env}",
ingress=[{"from_port": 8000, "to_port": 8000, "protocol": "tcp", "security_groups": [self._alb_sg.id]}],
egress=[{"from_port": 0, "to_port": 0, "protocol": "-1", "cidr_blocks": ["0.0.0.0/0"]}])
self._db_sg = SecurityGroup(self, "db-sg",
vpc_id=self._vpc.id,
name=f"db-sg-{env}",
ingress=[{"from_port": 5432, "to_port": 5432, "protocol": "tcp", "security_groups": [self._ecs_sg.id]}],
egress=[{"from_port": 0, "to_port": 0, "protocol": "-1", "cidr_blocks": ["0.0.0.0/0"]}])
# Expose outputs for other stacks
TerraformOutput(self, "vpc-id", value=self._vpc.id)
@property
def vpc_id(self) -> str:
return self._vpc.id
@property
def public_subnet_ids(self) -> list[str]:
return [s.id for s in self._public_subnets]
@property
def private_subnet_ids(self) -> list[str]:
return [s.id for s in self._private_subnets]
@property
def alb_sg_id(self) -> str:
return self._alb_sg.id
@property
def ecs_sg_id(self) -> str:
return self._ecs_sg.id
@property
def db_sg_id(self) -> str:
return self._db_sg.id
ECS Fargate Service
# stacks/compute.py (abbreviated)
from cdktf import TerraformStack
from cdktf_cdktf_provider_aws.ecs_cluster import EcsCluster
from cdktf_cdktf_provider_aws.ecs_task_definition import EcsTaskDefinition
from cdktf_cdktf_provider_aws.ecs_service import EcsService
from cdktf_cdktf_provider_aws.lb import Lb
from cdktf_cdktf_provider_aws.lb_target_group import LbTargetGroup
from cdktf_cdktf_provider_aws.lb_listener import LbListener
import json
class ComputeStack(TerraformStack):
def __init__(self, scope, id, *, env, region, vpc_id, public_subnet_ids,
private_subnet_ids, alb_sg_id, ecs_sg_id, database_url):
super().__init__(scope, id)
from cdktf_cdktf_provider_aws.provider import AwsProvider
AwsProvider(self, "aws", region=region)
cluster = EcsCluster(self, "cluster", name=f"techoral-{env}")
# ALB
alb = Lb(self, "alb",
name=f"techoral-alb-{env}",
internal=False,
load_balancer_type="application",
security_groups=[alb_sg_id],
subnets=public_subnet_ids)
tg = LbTargetGroup(self, "tg",
name=f"techoral-tg-{env}",
port=8000,
protocol="HTTP",
vpc_id=vpc_id,
target_type="ip",
health_check={"path": "/health", "healthy_threshold": 2})
LbListener(self, "listener",
load_balancer_arn=alb.arn,
port=80, protocol="HTTP",
default_action=[{"type": "forward", "target_group_arn": tg.arn}])
# Task definition
task_def = EcsTaskDefinition(self, "task",
family=f"techoral-api-{env}",
requires_compatibilities=["FARGATE"],
network_mode="awsvpc",
cpu="256", memory="512",
container_definitions=json.dumps([{
"name": "api",
"image": f"registry.techoral.com/api:latest",
"portMappings": [{"containerPort": 8000}],
"environment": [
{"name": "DATABASE_URL", "value": database_url},
{"name": "ENVIRONMENT", "value": env},
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": f"/ecs/techoral-api-{env}",
"awslogs-region": region,
"awslogs-stream-prefix": "ecs",
}
}
}])
)
EcsService(self, "service",
name=f"techoral-api-{env}",
cluster=cluster.id,
task_definition=task_def.arn,
desired_count=2,
launch_type="FARGATE",
network_configuration={
"subnets": private_subnet_ids,
"security_groups": [ecs_sg_id],
"assign_public_ip": False,
},
load_balancer=[{
"target_group_arn": tg.arn,
"container_name": "api",
"container_port": 8000,
}])
RDS PostgreSQL
# stacks/database.py
from cdktf import TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.db_instance import DbInstance
from cdktf_cdktf_provider_aws.db_subnet_group import DbSubnetGroup
from cdktf_cdktf_provider_aws.ssm_parameter import SsmParameter
import secrets
class DatabaseStack(TerraformStack):
def __init__(self, scope, id, *, env, region, vpc_id, subnet_ids, security_group_id):
super().__init__(scope, id)
from cdktf_cdktf_provider_aws.provider import AwsProvider
AwsProvider(self, "aws", region=region)
db_password = secrets.token_urlsafe(32)
subnet_group = DbSubnetGroup(self, "subnet-group",
name=f"techoral-db-{env}",
subnet_ids=subnet_ids)
rds = DbInstance(self, "rds",
identifier=f"techoral-{env}",
engine="postgres",
engine_version="16.2",
instance_class="db.t4g.small",
allocated_storage=50,
max_allocated_storage=200,
storage_type="gp3",
storage_encrypted=True,
db_name="techoral",
username="techoral_admin",
password=db_password,
db_subnet_group_name=subnet_group.name,
vpc_security_group_ids=[security_group_id],
multi_az=env == "production",
backup_retention_period=7,
skip_final_snapshot=env != "production",
deletion_protection=env == "production",
tags={"Environment": env})
# Store password in SSM (ECS task retrieves it at startup)
SsmParameter(self, "db-password",
name=f"/techoral/{env}/db-password",
type="SecureString",
value=db_password)
self._database_url = f"postgresql://techoral_admin:{db_password}@{rds.endpoint}/techoral"
TerraformOutput(self, "db-endpoint", value=rds.endpoint)
@property
def database_url(self) -> str:
return self._database_url
Reusable Constructs
# constructs/fargate_service.py — reusable ECS Fargate construct
from constructs import Construct
from cdktf_cdktf_provider_aws.ecs_task_definition import EcsTaskDefinition
from cdktf_cdktf_provider_aws.ecs_service import EcsService
import json
class FargateService(Construct):
"""Reusable construct for an ECS Fargate service with ALB target group."""
def __init__(self, scope: Construct, id: str, *,
cluster_id: str, service_name: str, image: str,
cpu: str = "256", memory: str = "512",
desired_count: int = 2, port: int = 8000,
env_vars: dict | None = None,
subnet_ids: list[str],
security_group_ids: list[str],
target_group_arn: str | None = None):
super().__init__(scope, id)
container_defs = [{
"name": service_name,
"image": image,
"portMappings": [{"containerPort": port}],
"environment": [{"name": k, "value": v} for k, v in (env_vars or {}).items()],
}]
task = EcsTaskDefinition(self, "task",
family=service_name,
requires_compatibilities=["FARGATE"],
network_mode="awsvpc",
cpu=cpu, memory=memory,
container_definitions=json.dumps(container_defs))
lb_config = []
if target_group_arn:
lb_config = [{"target_group_arn": target_group_arn,
"container_name": service_name, "container_port": port}]
self.service = EcsService(self, "service",
name=service_name,
cluster=cluster_id,
task_definition=task.arn,
desired_count=desired_count,
launch_type="FARGATE",
network_configuration={
"subnets": subnet_ids,
"security_groups": security_group_ids,
"assign_public_ip": False,
},
load_balancer=lb_config)
# Usage — reuse the construct for any service
# api = FargateService(self, "api-service", cluster_id=cluster.id, service_name="api", image="...", ...)
# worker = FargateService(self, "worker", cluster_id=cluster.id, service_name="worker", image="...", ...)
Testing Infrastructure Code
# tests/test_network_stack.py
import pytest
from cdktf import App, Testing
from stacks.network import NetworkStack
class TestNetworkStack:
def setup_method(self):
self.app = App()
self.stack = NetworkStack(self.app, "test-network", env="test", region="ap-south-1")
def test_vpc_created(self):
synth = Testing.synth(self.stack)
assert "aws_vpc" in synth
def test_two_public_subnets(self):
synth = Testing.synth(self.stack)
subnets = synth.get("aws_subnet", {})
public_subnets = [k for k in subnets if "public" in k]
assert len(public_subnets) == 2
def test_nat_gateway_exists(self):
synth = Testing.synth(self.stack)
assert "aws_nat_gateway" in synth
def test_security_groups_count(self):
synth = Testing.synth(self.stack)
sgs = synth.get("aws_security_group", {})
# alb-sg, ecs-sg, db-sg
assert len(sgs) >= 3
# Synthesize to Terraform JSON
cdktf synth
# Plan (shows what Terraform will create)
cdktf diff
# Deploy (runs terraform apply)
cdktf deploy techoral-network-production
cdktf deploy techoral-db-production
cdktf deploy techoral-compute-production
# Destroy
cdktf destroy techoral-compute-production
# Run tests
pytest tests/
Frequently Asked Questions
- CDKTF vs AWS CDK vs Pulumi — which to use?
- Use CDKTF if you're already invested in Terraform (state files, providers, modules) and want to write Python instead of HCL. Use AWS CDK for AWS-only infrastructure — better AWS integrations, CloudFormation under the hood. Use Pulumi for a true Python runtime (dynamic values, secrets management) without the HCL intermediary. CDKTF is the best choice when migrating an existing Terraform codebase to code.
- Does CDKTF support Terraform modules?
- Yes — add modules to
cdktf.jsonunderterraformModulesand CDKTF generates Python classes for them. You can use any public Terraform module (VPC, EKS, RDS) as a Python class with type hints and autocompletion. - How do I handle secrets in CDKTF?
- Never hard-code secrets in Python code — they end up in Terraform state files. Use Terraform variables with
TerraformVariable(sensitive=True)passed via environment variables, or reference existing secrets with data sources (AwsSecretsmanagerSecretVersion). Use SSM Parameter Store or AWS Secrets Manager to store and retrieve secrets at runtime.