CDK for Terraform (CDKTF) with Python: Deploy Cloud Resources Without Writing HCL

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Why You’d Want to Replace HCL with Python

If you’ve spent any time with Terraform, you know HCL (HashiCorp Configuration Language) is perfectly fine — until you need a loop that does something slightly unusual, or you want to reuse logic across stacks without copy-pasting blocks everywhere. HCL is declarative by design, which is great for simple infrastructure, but the moment complexity creeps in, you start wishing you could just write a function.

That’s exactly the gap CDK for Terraform fills. CDKTF lets you write your infrastructure code in Python (or TypeScript, Go, Java, C#) and generates Terraform JSON configuration under the hood. Your actual Terraform state, providers, and backend all stay the same — you’re just using a real programming language to define them.

I’ve applied this approach in production across multiple AWS environments, and the results have been consistently stable. The generated Terraform JSON is valid, predictable, and plays well with existing CI/CD pipelines that already run terraform plan and terraform apply.

When CDKTF Makes Sense

  • You need conditional logic or loops that feel awkward in HCL
  • Your team already knows Python and finds HCL syntax unfamiliar
  • You want to share infrastructure patterns as Python packages
  • You’re building internal developer platforms where infra is generated dynamically

If your infrastructure is small and straightforward, plain Terraform is probably fine. But once you’re managing dozens of environments with shared configurations, CDKTF starts to pay dividends.

Installation

CDKTF has two runtime requirements: Node.js (for the CLI) and Python (for your actual stack code). You need both even though you’re writing Python — the CLI is a Node.js tool.

Step 1: Install Node.js

The CDKTF CLI requires Node.js 18 or later. If you’re on Ubuntu/Debian:

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version  # should be v20.x or newer

On macOS with Homebrew:

brew install node

Step 2: Install the CDKTF CLI

npm install -g cdktf-cli
cdktf --version  # confirm the install

Step 3: Install Terraform

CDKTF generates Terraform configuration, so you still need Terraform itself to do the actual provisioning:

# On Ubuntu/Debian
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform --version

Step 4: Bootstrap a New CDKTF Project

Create a new directory and initialize a Python project:

mkdir my-cdktf-project && cd my-cdktf-project
cdktf init --template=python --local

The --local flag stores Terraform state locally (a terraform.tfstate file). For production, you’d point this at S3 or Terraform Cloud, but local state is fine for learning.

After initialization, your directory structure looks like this:

my-cdktf-project/
├── main.py          ← your stack definition goes here
├── cdktf.json       ← CDKTF config (providers, output dir)
├── requirements.txt
└── .gen/            ← auto-generated provider bindings (don't edit)

Step 5: Set Up the Python Virtual Environment

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Configuration

Now the interesting part — writing actual infrastructure in Python. This example deploys an AWS S3 bucket with versioning enabled. Small example, but it demonstrates the full pattern you’ll use for any resource.

Add the AWS Provider

Edit cdktf.json to include the AWS provider:

{
  "language": "python",
  "app": "pipenv run python main.py",
  "terraformProviders": ["aws@~> 5.0"],
  "terraformModules": [],
  "output": "cdktf.out"
}

Then generate the provider bindings. This downloads the AWS provider schema and generates typed Python classes for every AWS resource:

cdktf get

This takes a minute or two. When it finishes, you’ll see thousands of classes under .gen/providers/aws/ — one for each AWS resource type.

Write Your Stack in Python

Open main.py and replace the default content:

from constructs import Construct
from cdktf import App, TerraformStack, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
from cdktf_cdktf_provider_aws.s3_bucket_versioning import (
    S3BucketVersioning,
    S3BucketVersioningVersioningConfiguration,
)


class MyInfraStack(TerraformStack):
    def __init__(self, scope: Construct, id: str, env: str):
        super().__init__(scope, id)

        # Configure the AWS provider
        AwsProvider(self, "AWS", region="ap-northeast-1")

        # Create an S3 bucket
        bucket = S3Bucket(
            self,
            "app-bucket",
            bucket=f"my-app-assets-{env}",
            tags={"Environment": env, "ManagedBy": "cdktf"},
        )

        # Enable versioning on that bucket
        S3BucketVersioning(
            self,
            "bucket-versioning",
            bucket=bucket.id,
            versioning_configuration=S3BucketVersioningVersioningConfiguration(
                status="Enabled"
            ),
        )

        # Output the bucket name so we can reference it later
        TerraformOutput(
            self,
            "bucket_name",
            value=bucket.bucket,
            description="The name of the created S3 bucket",
        )


app = App()
MyInfraStack(app, "staging", env="staging")
MyInfraStack(app, "production", env="production")
app.synth()

Notice what’s happening here: the env parameter lets you instantiate the same stack twice — once for staging, once for production — without duplicating anything. This is where Python shines over HCL; parameterized stacks are just class instances.

Synthesize the Terraform Configuration

CDKTF doesn’t provision directly — it first generates Terraform JSON, then you apply it. Run:

cdktf synth

This creates cdktf.out/stacks/staging/ and cdktf.out/stacks/production/, each containing a cdk.tf.json file. You can inspect that file to verify what CDKTF generated — it’s standard Terraform JSON and you can read it like any Terraform config.

Deploy to AWS

Make sure your AWS credentials are configured (environment variables, ~/.aws/credentials, or IAM role), then:

# Preview changes before applying
cdktf plan staging

# Apply to staging
cdktf deploy staging

# Apply to both stacks at once
cdktf deploy --all

You’ll see familiar Terraform plan output — CDKTF just passes through to the underlying terraform apply for each stack.

Verification and Monitoring

Once your deployment completes, verify everything landed correctly and set yourself up to catch drift early.

Check Stack Outputs

After cdktf deploy completes, outputs are printed directly in the terminal. To query them again later:

cdktf output staging

You’ll see the bucket_name output we defined — useful for passing values between stacks or into application configuration.

Verify the Resource in AWS

# List your buckets and confirm the new one exists
aws s3 ls | grep my-app-assets

# Check versioning is actually enabled
aws s3api get-bucket-versioning --bucket my-app-assets-staging

Expected output for the versioning check:

{
    "Status": "Enabled"
}

Detect Configuration Drift

If someone manually changes a resource in the AWS console, CDKTF (via Terraform) will detect the drift on the next plan:

cdktf diff staging

This runs terraform plan against the current state and shows what’s changed outside of your code. Running this in CI on a schedule is a cheap way to catch unauthorized changes before they cause problems.

Destroy Resources When Done

For testing environments you want to tear down:

cdktf destroy staging

Always double-check this command before running it on anything production-adjacent — it’s irreversible for stateful resources like databases and S3 buckets with data.

Integrate with CI/CD

A minimal GitHub Actions workflow to plan on pull requests and apply on merge:

name: CDKTF Deploy
on:
  push:
    branches: [main]
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: npm install -g cdktf-cli
      - run: pip install -r requirements.txt
      - run: cdktf get
      - name: Plan (PR only)
        if: github.event_name == 'pull_request'
        run: cdktf plan --all
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      - name: Deploy (main branch only)
        if: github.ref == 'refs/heads/main'
        run: cdktf deploy --all --auto-approve
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

The pattern here — plan on PRs, apply on merge — is the same workflow teams use with plain Terraform. CDKTF slots in without disrupting existing processes.

One thing to keep in mind: the cdktf get step regenerates provider bindings. Pin your provider version in cdktf.json to avoid surprises when HashiCorp releases a new provider version mid-pipeline.

Share: