Post
Available in: Português

CDKTF in Go: write your Terraform infrastructure in pure Go

Hey everyone!

If you have ever written a custom Terraform provider in Go, you know the language and the ecosystem fit well together. But when it comes time to write the actual infrastructure, you go back to HCL.

CDKTF changes that. With it, you define your AWS, GCP, or any other provider resources directly in Go. Loops, functions, structs, unit tests for your infra. HCL becomes an implementation detail you never have to look at.


How it works

CDKTF does not replace Terraform. It sits on top of it. You write Go, run cdktf synth, and it generates the JSON that Terraform understands. The terraform apply happens normally underneath.

1
Your Go code → cdktf synth → terraform.json → terraform apply → infra created

You get the expressiveness of a real programming language without giving up the Terraform ecosystem: providers, state, execution plans, all of it stays the same.


Installing the CDKTF CLI

1
npm install -g cdktf-cli

Yes, the CLI is Node. But the code you write is pure Go. The CLI is just scaffolding.

Check the installation:

1
cdktf --version

Creating the project

1
2
mkdir my-infra && cd my-infra
cdktf init --template=go --local

The --local flag uses local state instead of Terraform Cloud. A good option to start.

Generated structure:

1
2
3
4
5
6
my-infra/
  main.go
  go.mod
  go.sum
  cdktf.json
  help/

The cdktf.json defines the providers you will use:

1
2
3
4
5
6
{
  "language": "go",
  "app": "go run main.go",
  "terraformProviders": ["aws@~> 5.0"],
  "terraformModules": []
}

Now generate the Go bindings for the AWS provider:

1
cdktf get

This downloads and generates the Go package with all AWS resources fully typed. Takes a few minutes the first time.


First stack: an S3 bucket

The generated main.go has the base structure. Let us create an S3 bucket:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// main.go
package main

import (
	"github.com/aws/constructs-go/constructs/v10"
	"github.com/aws/jsii-runtime-go"
	"github.com/hashicorp/terraform-cdk-go/cdktf"

	aws "github.com/cdktf/cdktf-provider-aws-go/aws/v19/provider"
	s3 "github.com/cdktf/cdktf-provider-aws-go/aws/v19/s3bucket"
)

func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
	stack := cdktf.NewTerraformStack(scope, &id)

	aws.NewAwsProvider(stack, jsii.String("AWS"), &aws.AwsProviderConfig{
		Region: jsii.String("us-east-1"),
	})

	s3.NewS3Bucket(stack, jsii.String("my-bucket"), &s3.S3BucketConfig{
		Bucket: jsii.String("my-bucket-huncoding"),
	})

	return stack
}

func main() {
	app := cdktf.NewApp(nil)
	NewMyStack(app, "my-stack")
	app.Synth()
}

One thing that stands out: jsii.String("value") instead of just "value". CDKTF uses the JSII runtime underneath, which requires pointers on all fields. It is verbose, but you get used to it quickly.

Generate the JSON:

1
cdktf synth

This creates cdktf.out/stacks/my-stack/cdk.tf.json with the Terraform JSON equivalent to the HCL you would have written. You never need to touch that file.


Where Go starts to make a difference

Creating a single bucket is simple in HCL too. CDKTF starts to shine when you have real logic.

Multiple environments with a loop

In HCL, creating the same resource for dev, staging, and prod requires for_each or modules. In Go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func NewMyStack(scope constructs.Construct, id string) cdktf.TerraformStack {
	stack := cdktf.NewTerraformStack(scope, &id)

	aws.NewAwsProvider(stack, jsii.String("AWS"), &aws.AwsProviderConfig{
		Region: jsii.String("us-east-1"),
	})

	envs := []string{"dev", "staging", "prod"}

	for _, env := range envs {
		env := env
		s3.NewS3Bucket(stack, jsii.String("bucket-"+env), &s3.S3BucketConfig{
			Bucket: jsii.String("huncoding-assets-" + env),
			Tags: &map[string]*string{
				"Environment": jsii.String(env),
				"ManagedBy":   jsii.String("cdktf"),
			},
		})
	}

	return stack
}

Three buckets created, each with its own tags, in a three-line loop.

Reusable constructs

You can extract patterns into normal Go functions. A bucket with versioning and public access blocking that every project uses:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type AppBucketConfig struct {
	Name        string
	Environment string
}

func NewAppBucket(stack cdktf.TerraformStack, config AppBucketConfig) s3.S3Bucket {
	bucket := s3.NewS3Bucket(stack, jsii.String("bucket-"+config.Name), &s3.S3BucketConfig{
		Bucket: jsii.String(config.Name),
		Tags: &map[string]*string{
			"Environment": jsii.String(config.Environment),
		},
	})

	s3.NewS3BucketVersioningA(stack, jsii.String("versioning-"+config.Name), &s3.S3BucketVersioningAConfig{
		Bucket: bucket.Bucket(),
		VersioningConfiguration: &s3.S3BucketVersioningAVersioningConfiguration{
			Status: jsii.String("Enabled"),
		},
	})

	s3.NewS3BucketPublicAccessBlock(stack, jsii.String("public-access-"+config.Name), &s3.S3BucketPublicAccessBlockConfig{
		Bucket:                bucket.Id(),
		BlockPublicAcls:       jsii.Bool(true),
		BlockPublicPolicy:     jsii.Bool(true),
		IgnorePublicAcls:      jsii.Bool(true),
		RestrictPublicBuckets: jsii.Bool(true),
	})

	return bucket
}

Now anywhere in the project:

1
2
3
4
NewAppBucket(stack, AppBucketConfig{
	Name:        "huncoding-uploads",
	Environment: "prod",
})

One construct that creates bucket, versioning, and public access blocking, always with the same security settings, no duplication.


Multiple stacks

Larger projects organize infrastructure into separate stacks: networking, database, application. In Go that is just creating more structs:

1
2
3
4
5
6
7
8
9
func main() {
	app := cdktf.NewApp(nil)

	NewNetworkStack(app, "network")
	NewDatabaseStack(app, "database")
	NewAppStack(app, "application")

	app.Synth()
}

Each stack has its own state file. You deploy each one independently.


Deploying

With AWS credentials configured:

1
2
3
4
5
6
7
8
# Execution plan
cdktf diff

# Deploy
cdktf deploy

# Destroy everything
cdktf destroy

cdktf diff is equivalent to terraform plan. Shows what will be created, modified, or destroyed before applying.


Testing the infrastructure

A real advantage of CDKTF in Go: you can test JSON generation with normal unit tests, without needing real infrastructure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// main_test.go
package main

import (
	"encoding/json"
	"testing"

	"github.com/hashicorp/terraform-cdk-go/cdktf"
	"github.com/stretchr/testify/assert"
)

func TestBucketsCreated(t *testing.T) {
	app := cdktf.NewApp(nil)
	stack := NewMyStack(app, "test-stack")

	synth := cdktf.Testing().SynthScope(stack)

	var config map[string]any
	json.Unmarshal([]byte(synth), &config)

	resources := config["resource"].(map[string]any)
	buckets := resources["aws_s3_bucket"].(map[string]any)

	assert.Contains(t, buckets, "bucket-dev")
	assert.Contains(t, buckets, "bucket-staging")
	assert.Contains(t, buckets, "bucket-prod")
}

This creates nothing on AWS. It only verifies that the generated JSON has the expected resources. Runs in CI without credentials.


Is it worth migrating?

You do not need to migrate anything. CDKTF works alongside existing HCL. You can start using it for new modules while the rest stays in HCL.

It makes more sense for teams that already write Go and want consistency in the stack, or for infrastructure with a lot of conditional logic and repetition that would be hard to read in HCL.

If your infrastructure is simple and stable, HCL is still the more straightforward option.


Conclusion

CDKTF is not a solution for everything, but it fills a real gap: programming logic in infrastructure definition, without giving up Terraform underneath. And in Go, with the typing and reuse that a real language offers, the difference from HCL becomes clear quickly.

If you have already written a custom provider, the natural next step is to define the infrastructure that uses that provider in the same language.


References