DEV Community

Cover image for Terraform CDK + Go: Building a Complete Serverless API Project (Part 1)
Mark Gerald Martins
Mark Gerald Martins

Posted on

Terraform CDK + Go: Building a Complete Serverless API Project (Part 1)

🚀 Infrastructure as Code in action!

If you already use Terraform but want the power of programming languages like Go, you’ll love this tutorial I just published.

I show you step by step how to build a complete Serverless API on AWS with Lambda + API Gateway + DynamoDB + S3, using Terraform CDK (CDKTF).

File‑by‑file, hands‑on guide to build a serverless Orders API on AWS with Terraform CDK (CDKTF) + Go. We’ll create every folder and file under terraform/, explain what each piece does, and deploy. Part 2 (coming soon) will add CI/CD with GitHub Actions for both the API and the CDKTF code using GitOps.


Why CDKTF (CDK for Terraform) is great

  • Type‑safe infra in Go (structs, IDE autocomplete, refactors).
  • Reusable modules/constructs instead of huge .tf files.
  • Still Terraform under the hood (plan/apply, state backends, providers).
  • Easy to grow into multi‑stack projects.

What we’ll build

A small Orders API backed by AWS Lambda (Go), exposed by API Gateway HTTP API, storing data in DynamoDB, plus an S3 bucket to host the Lambda artifact (api.zip).

Endpoints in the app (outside scope here) include: /orders, /orders/:orderId, /orders/:orderId/items, etc. We’ll focus on the infra.


Requirements

  • Go ≥ 1.21/1.22
  • Node.js + npm (for the CDKTF CLI)
  • Terraform CLI ≥ 1.5
  • AWS CLI configured (profile or env vars)

Install CDKTF CLI:

npm i -g cdktf-cli@latest
cdktf --version
Enter fullscreen mode Exit fullscreen mode

Repo context

Base project: go-serverless-api-terraform (contains the Go API code). We’ll create all infra files under terraform/ (matching the branch terraform-cdk-go).

Clone and enter:

git clone https://github.com/markgerald/go-serverless-api-terraform.git
cd go-serverless-api-terraform
mkdir -p terraform && cd terraform
Enter fullscreen mode Exit fullscreen mode

Final folder layout (everything we’ll create now)

terraform/
├─ cdktf.json
├─ go.mod
├─ go.sum             # (auto‑generated)
├─ .env.sample
├─ main.go            # App entrypoint: reads env, builds stack, synths
├─ infra/
│  └─ infra.go        # Stack wiring (provider, modules, outputs)
└─ modules/
   ├─ dynamodb/
   │  └─ dynamodb.go  # Orders + Order Items tables
   ├─ s3/
   │  └─ bucket.go    # Artifact bucket + PAB + versioning/encryption
   ├─ iam/
   │  └─ role.go      # Lambda execution role + policies
   ├─ lambda/
   │  └─ lambda.go    # Lambda from S3 object + env vars
   └─ apigateway/
      └─ api.go       # HTTP API + integration + routes + stage + permission
Enter fullscreen mode Exit fullscreen mode

We’ll build this tree step by step ⬇️


1) cdktf.json — project config

Create terraform/cdktf.json:

{
  "language": "go",
  "app": "go run main.go",
  "terraformProviders": [
    "hashicorp/aws@~> 6.0"
  ],
  "context": {
    "awsRegion": "us-east-1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Adjust awsRegion to your default. We’ll still allow overriding via AWS_REGION.


2) go.mod — module + dependencies

Create terraform/go.mod:

module terraform

go 1.22

require (
    github.com/aws/constructs-go/constructs/v10 v10.3.0
    github.com/cdktf/cdktf-provider-aws-go/aws/v21 v21.0.0
    github.com/hashicorp/terraform-cdk-go/cdktf v0.21.0
)
Enter fullscreen mode Exit fullscreen mode

Fetch deps:

go mod tidy
Enter fullscreen mode Exit fullscreen mode

3) .env.sample — variables used by the stack

Create terraform/.env.sample:

# Common
AWS_REGION=us-east-1
AWS_PROFILE=
ENV=dev

# DynamoDB
TABLE_ORDERS=orders
TABLE_ORDER_ITEMS=order_items

# S3 artifact bucket
S3_BUCKET_PREFIX=go-lambda-artifacts
S3_VERSIONING=false      # true/false
S3_FORCE_DESTROY=false   # true/false (careful!)
S3_ENCRYPTION=NONE       # NONE|AES256|KMS
S3_KMS_KEY_ID=

# Lambda
LAMBDA_NAME=go-lambda-api
LAMBDA_RUNTIME=provided.al2023
LAMBDA_HANDLER=bootstrap
LAMBDA_MEMORY=128
LAMBDA_TIMEOUT=10
LAMBDA_S3_KEY=api.zip
Enter fullscreen mode Exit fullscreen mode

Copy to .env and export, or set directly in your shell before running.


4) main.go — entrypoint (reads env, builds stack, synths)

Create terraform/main.go:

package main

import (
    "log"
    "os"

    "github.com/aws/jsii-runtime-go"
    "github.com/hashicorp/terraform-cdk-go/cdktf"

    "terraform/infra"
)

func getenv(k, def string) string {
    if v := os.Getenv(k); v != "" {
        return v
    }
    return def
}

func main() {
    // Read env (with sane defaults)
    cfg := infra.Config{
        AwsRegion:        getenv("AWS_REGION", "us-east-1"),
        AwsProfile:       os.Getenv("AWS_PROFILE"),
        Env:              getenv("ENV", "dev"),
        TableOrders:      getenv("TABLE_ORDERS", "orders"),
        TableOrderItems:  getenv("TABLE_ORDER_ITEMS", "order_items"),
        S3BucketPrefix:   getenv("S3_BUCKET_PREFIX", "go-lambda-artifacts"),
        S3Versioning:     getenv("S3_VERSIONING", "false"),
        S3ForceDestroy:   getenv("S3_FORCE_DESTROY", "false"),
        S3Encryption:     getenv("S3_ENCRYPTION", "NONE"),
        S3KmsKeyID:       os.Getenv("S3_KMS_KEY_ID"),
        LambdaName:       getenv("LAMBDA_NAME", "go-lambda-api"),
        LambdaRuntime:    getenv("LAMBDA_RUNTIME", "provided.al2023"),
        LambdaHandler:    getenv("LAMBDA_HANDLER", "bootstrap"),
        LambdaMemory:     getenv("LAMBDA_MEMORY", "128"),
        LambdaTimeout:    getenv("LAMBDA_TIMEOUT", "10"),
        LambdaS3Key:      getenv("LAMBDA_S3_KEY", "api.zip"),
    }

    app := cdktf.NewApp(nil)

    if _, err := infra.NewStack(app, "infra", &cfg); err != nil {
        log.Fatalf("failed to build stack: %v", err)
    }

    app.Synth() // generates cdktf.out/stacks/infra
    log.Printf("Synth complete. Now: cd cdktf.out/stacks/infra && terraform init")
}
Enter fullscreen mode Exit fullscreen mode

5) infra/infra.go — wire provider + modules + outputs

Create folder and file:

mkdir -p infra && touch infra/infra.go
Enter fullscreen mode Exit fullscreen mode

Add:

package infra

import (
    "fmt"

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

    awsprovider "github.com/cdktf/cdktf-provider-aws-go/aws/v21/provider"

    mddb "terraform/modules/dynamodb"
    mds3 "terraform/modules/s3"
    mdrole "terraform/modules/iam"
    mdlambda "terraform/modules/lambda"
    mdapi "terraform/modules/apigateway"
)

type Config struct {
    AwsRegion, AwsProfile string
    Env string

    TableOrders, TableOrderItems string

    S3BucketPrefix  string
    S3Versioning    string // "true"/"false"
    S3ForceDestroy  string // "true"/"false"
    S3Encryption    string // NONE|AES256|KMS
    S3KmsKeyID      string

    LambdaName, LambdaRuntime, LambdaHandler string
    LambdaMemory, LambdaTimeout, LambdaS3Key string
}

func NewStack(scope constructs.Construct, id string, cfg *Config) (cdktf.TerraformStack, error) {
    stack := cdktf.NewTerraformStack(scope, jsii.String(id))

    // Provider
    awsprovider.NewAwsProvider(stack, jsii.String("aws"), &awsprovider.AwsProviderConfig{
        Region:  jsii.String(cfg.AwsRegion),
        Profile: nilIfEmpty(cfg.AwsProfile),
    })

    // S3 bucket for artifacts
    bucket := mds3.NewArtifactBucket(stack, "Artifacts", &mds3.BucketConfig{
        Prefix:       cfg.S3BucketPrefix,
        Env:          cfg.Env,
        Versioning:   cfg.S3Versioning == "true" || cfg.S3Versioning == "1",
        ForceDestroy: cfg.S3ForceDestroy == "true" || cfg.S3ForceDestroy == "1",
        Encryption:   cfg.S3Encryption,
        KmsKeyID:     cfg.S3KmsKeyID,
    })

    // DynamoDB tables
    tables := mddb.NewTables(stack, "Tables", &mddb.TablesConfig{
        OrdersName:     cfg.TableOrders,
        OrderItemsName: cfg.TableOrderItems,
    })

    // IAM role for Lambda
    role := mdrole.NewLambdaRole(stack, "LambdaRole")

    // Lambda function from S3 object
    fn := mdlambda.NewApiLambda(stack, "ApiLambda", &mdlambda.LambdaConfig{
        Name:       cfg.LambdaName,
        Runtime:    cfg.LambdaRuntime,
        Handler:    cfg.LambdaHandler,
        Memory:     cfg.LambdaMemory,
        Timeout:    cfg.LambdaTimeout,
        S3Bucket:   bucket.Name(),
        S3Key:      cfg.LambdaS3Key,
        Env:        cfg.Env,
        TableOrders:     tables.OrdersName(),
        TableOrderItems: tables.OrderItemsName(),
        RoleArn:    role.Arn(),
    })

    // API Gateway HTTP API + routes + integration + permission
    api := mdapi.NewHttpApi(stack, "HttpApi", &mdapi.ApiConfig{
        Env:          cfg.Env,
        LambdaArn:    fn.Arn(),
        LambdaName:   fn.FunctionName(),
    })

    // Outputs
    cdktf.NewTerraformOutput(stack, jsii.String("api_url"), &cdktf.TerraformOutputConfig{
        Value: api.Endpoint(),
    })

    cdktf.NewTerraformOutput(stack, jsii.String("artifact_bucket"), &cdktf.TerraformOutputConfig{
        Value: bucket.Name(),
    })

    return stack, nil
}

func nilIfEmpty(s string) *string {
    if s == "" {
        return nil
    }
    return jsii.String(s)
}
Enter fullscreen mode Exit fullscreen mode

6) Modules — one file at a time

Create the modules tree:

mkdir -p modules/dynamodb modules/s3 modules/iam modules/lambda modules/apigateway
Enter fullscreen mode Exit fullscreen mode

6.1) modules/dynamodb/dynamodb.go

package dynamodb

import (
    "github.com/aws/constructs-go/constructs/v10"
    "github.com/aws/jsii-runtime-go"
    "github.com/hashicorp/terraform-cdk-go/cdktf"
    dynamodbtable "github.com/cdktf/cdktf-provider-aws-go/aws/v21/dynamodbtable"
    ddbpitr "github.com/cdktf/cdktf-provider-aws-go/aws/v21/dynamodbtablepointintimerecovery"
)

type TablesConfig struct {
    OrdersName     string
    OrderItemsName string
}

type Tables struct {
    orders     dynamodbtable.DynamodbTable
    orderItems dynamodbtable.DynamodbTable
}

func NewTables(scope constructs.Construct, id string, cfg *TablesConfig) *Tables {
    s := constructs.NewConstruct(scope, jsii.String(id))

    orders := dynamodbtable.NewDynamodbTable(s, jsii.String("Orders"), &dynamodbtable.DynamodbTableConfig{
        Name:        jsii.String(cfg.OrdersName),
        BillingMode: jsii.String("PAY_PER_REQUEST"),
        HashKey:     jsii.String("id"),
        Attribute: &[]*dynamodbtable.DynamodbTableAttribute{{
            Name: jsii.String("id"), Type: jsii.String("S"),
        }},
    })
    ddbpitr.NewDynamodbTablePointInTimeRecovery(s, jsii.String("OrdersPITR"), &ddbpitr.DynamodbTablePointInTimeRecoveryConfig{
        TableName: orders.Name(),
        Enabled:   jsii.Bool(true),
    })

    items := dynamodbtable.NewDynamodbTable(s, jsii.String("OrderItems"), &dynamodbtable.DynamodbTableConfig{
        Name:        jsii.String(cfg.OrderItemsName),
        BillingMode: jsii.String("PAY_PER_REQUEST"),
        HashKey:     jsii.String("order_id"),
        RangeKey:    jsii.String("id"),
        Attribute: &[]*dynamodbtable.DynamodbTableAttribute{
            { Name: jsii.String("order_id"), Type: jsii.String("S") },
            { Name: jsii.String("id"),       Type: jsii.String("S") },
        },
    })
    ddbpitr.NewDynamodbTablePointInTimeRecovery(s, jsii.String("OrderItemsPITR"), &ddbpitr.DynamodbTablePointInTimeRecoveryConfig{
        TableName: items.Name(),
        Enabled:   jsii.Bool(true),
    })

    return &Tables{orders: orders, orderItems: items}
}

func (t *Tables) OrdersName() *string     { return t.orders.Name() }
func (t *Tables) OrderItemsName() *string { return t.orderItems.Name() }
Enter fullscreen mode Exit fullscreen mode

6.2) modules/s3/bucket.go

package s3

import (
    "fmt"

    "github.com/aws/constructs-go/constructs/v10"
    "github.com/aws/jsii-runtime-go"
    s3bucket "github.com/cdktf/cdktf-provider-aws-go/aws/v21/s3bucket"
    s3pab "github.com/cdktf/cdktf-provider-aws-go/aws/v21/s3bucketpublicaccessblock"
    s3ver "github.com/cdktf/cdktf-provider-aws-go/aws/v21/s3bucketversioning"
    s3serverenc "github.com/cdktf/cdktf-provider-aws-go/aws/v21/s3bucketserverSideencryptionconfiguration"
)

type BucketConfig struct {
    Prefix       string
    Env          string
    Versioning   bool
    ForceDestroy bool
    Encryption   string // NONE|AES256|KMS
    KmsKeyID     string
}

type Bucket struct {
    bucket s3bucket.S3Bucket
}

func NewArtifactBucket(scope constructs.Construct, id string, cfg *BucketConfig) *Bucket {
    s := constructs.NewConstruct(scope, jsii.String(id))
    name := fmt.Sprintf("%s-%s", cfg.Prefix, cfg.Env)

    b := s3bucket.NewS3Bucket(s, jsii.String("ArtifactsBucket"), &s3bucket.S3BucketConfig{
        Bucket:       jsii.String(name),
        ForceDestroy: jsii.Bool(cfg.ForceDestroy),
    })

    s3pab.NewS3BucketPublicAccessBlock(s, jsii.String("PAB"), &s3pab.S3BucketPublicAccessBlockConfig{
        Bucket:                b.Bucket(),
        BlockPublicAcls:       jsii.Bool(true),
        BlockPublicPolicy:     jsii.Bool(true),
        IgnorePublicAcls:      jsii.Bool(true),
        RestrictPublicBuckets: jsii.Bool(true),
    })

    s3ver.NewS3BucketVersioning(s, jsii.String("Versioning"), &s3ver.S3BucketVersioningConfig{
        Bucket: b.Bucket(),
        VersioningConfiguration: &s3ver.S3BucketVersioningVersioningConfiguration{
            Status: jsii.String(ifThen(cfg.Versioning, "Enabled", "Suspended")),
        },
    })

    if cfg.Encryption == "AES256" || cfg.Encryption == "KMS" {
        var rule *s3serverenc.S3BucketServerSideEncryptionConfigurationRule
        if cfg.Encryption == "AES256" {
            rule = &s3serverenc.S3BucketServerSideEncryptionConfigurationRule{
                ApplyServerSideEncryptionByDefault: &s3serverenc.S3BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefault{
                    SseAlgorithm: jsii.String("AES256"),
                },
            }
        } else {
            rule = &s3serverenc.S3BucketServerSideEncryptionConfigurationRule{
                ApplyServerSideEncryptionByDefault: &s3serverenc.S3BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefault{
                    SseAlgorithm: jsii.String("aws:kms"),
                    KmsMasterKeyId: nilIfEmpty(cfg.KmsKeyID),
                },
            }
        }
        s3serverenc.NewS3BucketServerSideEncryptionConfiguration(s, jsii.String("Enc"), &s3serverenc.S3BucketServerSideEncryptionConfigurationConfig{
            Bucket: b.Bucket(),
            Rule:   &[]*s3serverenc.S3BucketServerSideEncryptionConfigurationRule{rule},
        })
    }

    return &Bucket{bucket: b}
}

func (b *Bucket) Name() *string { return b.bucket.Bucket() }

func ifThen[T any](cond bool, a, b T) T { if cond { return a }; return b }
func nilIfEmpty(s string) *string { if s == "" { return nil }; return jsii.String(s) }
Enter fullscreen mode Exit fullscreen mode

6.3) modules/iam/role.go

package iam

import (
    "github.com/aws/constructs-go/constructs/v10"
    "github.com/aws/jsii-runtime-go"
    iamrole "github.com/cdktf/cdktf-provider-aws-go/aws/v21/iamrole"
    iampa "github.com/cdktf/cdktf-provider-aws-go/aws/v21/iamrolepolicyattachment"
)

type Role struct{ role iamrole.IamRole }

func NewLambdaRole(scope constructs.Construct, id string) *Role {
    s := constructs.NewConstruct(scope, jsii.String(id))

    assume := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}`

    r := iamrole.NewIamRole(s, jsii.String("LambdaExecRole"), &iamrole.IamRoleConfig{
        Name:             jsii.String("orders-api-lambda-role"),
        AssumeRolePolicy: jsii.String(assume),
    })

    // Basic logging
    iampa.NewIamRolePolicyAttachment(s, jsii.String("BasicLogs"), &iampa.IamRolePolicyAttachmentConfig{
        Role:      r.Name(),
        PolicyArn: jsii.String("arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"),
    })

    return &Role{role: r}
}

func (r *Role) Arn() *string { return r.role.Arn() }
Enter fullscreen mode Exit fullscreen mode

Keep policies minimal. If your handler needs DynamoDB access directly, attach the specific permissions here later.

6.4) modules/lambda/lambda.go

package lambda

import (
    "github.com/aws/constructs-go/constructs/v10"
    "github.com/aws/jsii-runtime-go"
    lambdafn "github.com/cdktf/cdktf-provider-aws-go/aws/v21/lambdafunction"
)

type LambdaConfig struct {
    Name, Runtime, Handler string
    Memory, Timeout        string // keep as string for simplicity

    S3Bucket *string
    S3Key    string

    Env               string
    TableOrders       *string
    TableOrderItems   *string
    RoleArn           *string
}

type Fn struct{ fn lambdafn.LambdaFunction }

func NewApiLambda(scope constructs.Construct, id string, c *LambdaConfig) *Fn {
    s := constructs.NewConstruct(scope, jsii.String(id))

    mem := toNum(c.Memory, 128)
    tout := toNum(c.Timeout, 10)

    fn := lambdafn.NewLambdaFunction(s, jsii.String("Fn"), &lambdafn.LambdaFunctionConfig{
        FunctionName: jsii.String(c.Name),
        Role:         c.RoleArn,
        Runtime:      jsii.String(c.Runtime),
        Handler:      jsii.String(c.Handler),
        MemorySize:   jsii.Number(float64(mem)),
        Timeout:      jsii.Number(float64(tout)),
        S3Bucket:     c.S3Bucket,
        S3Key:        jsii.String(c.S3Key),
        Environment: &lambdafn.LambdaFunctionEnvironment{
            Variables: &map[string]*string{
                "ENV":               jsii.String(c.Env),
                "TABLE_ORDERS":      c.TableOrders,
                "TABLE_ORDER_ITEMS": c.TableOrderItems,
            },
        },
        Architectures: &[]*string{jsii.String("x86_64")},
    })

    return &Fn{fn: fn}
}

func (f *Fn) Arn() *string          { return f.fn.Arn() }
func (f *Fn) FunctionName() *string { return f.fn.FunctionName() }

func toNum(s string, def int) int { if s == "" { return def }; var n int; _, _ = fmt.Sscanf(s, "%d", &n); if n==0 {return def}; return n }
Enter fullscreen mode Exit fullscreen mode

6.5) modules/apigateway/api.go

package apigateway

import (
    "github.com/aws/constructs-go/constructs/v10"
    "github.com/aws/jsii-runtime-go"
    api "github.com/cdktf/cdktf-provider-aws-go/aws/v21/apigatewayv2api"
    integ "github.com/cdktf/cdktf-provider-aws-go/aws/v21/apigatewayv2integration"
    route "github.com/cdktf/cdktf-provider-aws-go/aws/v21/apigatewayv2route"
    stage "github.com/cdktf/cdktf-provider-aws-go/aws/v21/apigatewayv2stage"
    lperm "github.com/cdktf/cdktf-provider-aws-go/aws/v21/lambdapermission"
)

type ApiConfig struct {
    Env string
    LambdaArn  *string
    LambdaName *string
}

type Http struct {
    api api.Apigatewayv2Api
}

func NewHttpApi(scope constructs.Construct, id string, c *ApiConfig) *Http {
    s := constructs.NewConstruct(scope, jsii.String(id))

    a := api.NewApigatewayv2Api(s, jsii.String("Api"), &api.Apigatewayv2ApiConfig{
        Name:         jsii.String("orders-http"),
        ProtocolType: jsii.String("HTTP"),
    })

    ig := integ.NewApigatewayv2Integration(s, jsii.String("Integration"), &integ.Apigatewayv2IntegrationConfig{
        ApiId:                a.Id(),
        IntegrationType:      jsii.String("AWS_PROXY"),
        IntegrationUri:       c.LambdaArn,
        PayloadFormatVersion: jsii.String("2.0"),
    })

    // Minimal routes mapped to the Lambda (expand as needed)
    route.NewApigatewayv2Route(s, jsii.String("Orders"), &route.Apigatewayv2RouteConfig{
        ApiId:    a.Id(),
        RouteKey: jsii.String("ANY /orders"),
        Target:   jsii.String("integrations/" + *ig.Id()),
    })
    route.NewApigatewayv2Route(s, jsii.String("OrderItems"), &route.Apigatewayv2RouteConfig{
        ApiId:    a.Id(),
        RouteKey: jsii.String("ANY /orders/{orderId}/items"),
        Target:   jsii.String("integrations/" + *ig.Id()),
    })

    stage.NewApigatewayv2Stage(s, jsii.String("Stage"), &stage.Apigatewayv2StageConfig{
        ApiId:      a.Id(),
        Name:       jsii.String(c.Env),
        AutoDeploy: jsii.Bool(true),
    })

    lperm.NewLambdaPermission(s, jsii.String("AllowApiGw"), &lperm.LambdaPermissionConfig{
        Action:       jsii.String("lambda:InvokeFunction"),
        FunctionName: c.LambdaName,
        Principal:    jsii.String("apigateway.amazonaws.com"),
        SourceArn:    a.Arn(),
    })

    return &Http{api: a}
}

func (h *Http) Endpoint() *string { return h.api.ApiEndpoint() }
Enter fullscreen mode Exit fullscreen mode

7) Build & upload the Lambda artifact (api.zip)

From the repo root (not inside terraform/), build the custom runtime binary and zip it:

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bootstrap ./
zip api.zip bootstrap
Enter fullscreen mode Exit fullscreen mode

After the artifact bucket exists (next section step 4), upload:

aws s3 cp api.zip s3://<S3_BUCKET_PREFIX>-<ENV>/<LAMBDA_S3_KEY>
# ex.: aws s3 cp api.zip s3://go-lambda-artifacts-dev/api.zip
Enter fullscreen mode Exit fullscreen mode

8) Synthesize & apply with Terraform

From terraform/:

# 1) Synthesize CDKTF -> Terraform JSON
GOFLAGS=-mod=mod go run ./main.go

# 2) Initialize Terraform in the synthesized stack
cd cdktf.out/stacks/infra
terraform init

# 3) First create only the artifact bucket (so you can upload api.zip)
terraform apply -target=aws_s3_bucket.ArtifactsBucket -auto-approve

# 4) Upload the artifact (in another shell, see step 7)
aws s3 cp api.zip s3://<S3_BUCKET_PREFIX>-<ENV>/<LAMBDA_S3_KEY>

# 5) Apply the rest
terraform apply
Enter fullscreen mode Exit fullscreen mode

Grab the API URL from the Terraform outputs or the AWS console (API Gateway → Stages → your ENV).

Destroy when done:

terraform destroy
Enter fullscreen mode Exit fullscreen mode

If the bucket has objects, set S3_FORCE_DESTROY=true and re‑apply before destroying.


Notes & pitfalls

  • Runtime: provided.al2023 + handler bootstrap (custom Go runtime).
  • Keep IAM policies tight; start basic and iterate with least privilege.
  • S3 bucket names are global; prefix-env helps avoid collisions.
  • Consider configuring an S3 backend + DynamoDB locks later for team state.

Example backend (add in infra.NewStack when ready):

cdktf.NewS3Backend(stack, &cdktf.S3BackendConfig{
  Bucket: jsii.String("tf-state-bucket"),
  Key:    jsii.String("serverless-api/terraform.tfstate"),
  Region: jsii.String("us-east-1"),
  DynamoDbTable: jsii.String("terraform-locks"),
})
Enter fullscreen mode Exit fullscreen mode

Full project link

The complete, ready‑to‑run code lives here (same structure as above):

➡️ https://github.com/markgerald/go-serverless-api-terraform/tree/terraform-cdk-go


Coming in Part 2

  • GitHub Actions pipelines for:

    • Building, testing and releasing the Go API artifact
    • Synthesizing and deploying CDKTF changes
  • GitOps: PR‑driven infra changes with plans as checks, environments, and approvals.

Top comments (0)