Skip to main content

Terraform with Go: Infrastructure as Code for Go Services

Published: May 8, 2026 Updated: August 28, 2026 Larry Qu 6 min read

Terraform manages the cloud infrastructure your Go services run on — VPCs, databases, Kubernetes clusters, load balancers. This guide covers Terraform fundamentals, writing reusable modules, managing state safely, and using CDK for Terraform (CDKTF) to write infrastructure in Go itself.

Core Terraform Concepts

Terraform uses a declarative HCL language to describe desired infrastructure state, then figures out the minimal changes needed to get there.

terraform init       # download providers and initialize backend
terraform plan       # preview changes — shows what will be created/modified/destroyed
terraform apply      # apply the plan
terraform destroy    # tear down all managed resources

Basic Configuration for a Go Service

# main.tf
terraform {
  required_version = ">= 1.7"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.28"
    }
  }

  # Remote state — always use this in production
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "services/order-service/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"  # prevents concurrent state modifications
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}

Variables, Locals, and Outputs

# variables.tf
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "project_name" {
  description = "Project name — used in resource naming"
  type        = string
}

variable "go_service_image" {
  description = "Docker image for the Go service"
  type        = string
  # Example: "123456789.dkr.ecr.us-east-1.amazonaws.com/order-service:abc123"
}

# locals.tf — computed values, not inputs
locals {
  service_name = "${var.project_name}-${var.environment}"
  common_tags = {
    Service     = var.project_name
    Environment = var.environment
  }
}

# outputs.tf — values to expose to other Terraform configs or CI/CD
output "service_url" {
  description = "URL to access the service"
  value       = aws_lb.main.dns_name
}

output "ecr_repository_url" {
  description = "ECR URL for pushing Docker images"
  value       = aws_ecr_repository.app.repository_url
  sensitive   = false
}

Provisioning AWS Infrastructure for a Go Service

# ecr.tf — container registry
resource "aws_ecr_repository" "app" {
  name                 = local.service_name
  image_tag_mutability = "IMMUTABLE"  # prevent overwriting tags — use SHAs

  image_scanning_configuration {
    scan_on_push = true
  }

  lifecycle_policy_attachment {
    # Keep last 10 images, delete older ones
  }
}

# rds.tf — PostgreSQL for the Go service
resource "aws_db_instance" "app" {
  identifier        = local.service_name
  engine            = "postgres"
  engine_version    = "16.1"
  instance_class    = var.environment == "prod" ? "db.t3.medium" : "db.t3.micro"
  allocated_storage = 20
  storage_encrypted = true

  db_name  = replace(var.project_name, "-", "_")
  username = "app"
  password = random_password.db.result  # generated, stored in state

  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name

  backup_retention_period = var.environment == "prod" ? 7 : 1
  deletion_protection     = var.environment == "prod"
  skip_final_snapshot     = var.environment != "prod"

  tags = local.common_tags
}

resource "random_password" "db" {
  length  = 32
  special = false
}

# Store the password in Secrets Manager (not in state file)
resource "aws_secretsmanager_secret" "db_password" {
  name = "${local.service_name}/db-password"
}

resource "aws_secretsmanager_secret_version" "db_password" {
  secret_id     = aws_secretsmanager_secret.db_password.id
  secret_string = jsonencode({
    password = random_password.db.result
    url      = "postgres://app:${random_password.db.result}@${aws_db_instance.app.endpoint}/${aws_db_instance.app.db_name}"
  })
}

Reusable Modules

Break infrastructure into reusable modules:

modules/
  go-ecs-service/       # ECS Fargate service for Go apps
    main.tf
    variables.tf
    outputs.tf
  rds-postgres/         # PostgreSQL with secrets management
    main.tf
    variables.tf
    outputs.tf
  eks-cluster/          # EKS cluster
    main.tf
    variables.tf
    outputs.tf
# modules/go-ecs-service/main.tf
variable "service_name" { type = string }
variable "image"        { type = string }
variable "port"         { type = number; default = 8080 }
variable "cpu"          { type = number; default = 256 }
variable "memory"       { type = number; default = 512 }
variable "desired_count" { type = number; default = 2 }
variable "environment_vars" {
  type    = map(string)
  default = {}
}
variable "secrets" {
  type    = map(string)  # name → Secrets Manager ARN
  default = {}
}

resource "aws_ecs_task_definition" "main" {
  family                   = var.service_name
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name  = var.service_name
    image = var.image

    portMappings = [{ containerPort = var.port }]

    environment = [
      for k, v in var.environment_vars : { name = k, value = v }
    ]

    secrets = [
      for k, v in var.secrets : { name = k, valueFrom = v }
    ]

    healthCheck = {
      command     = ["CMD-SHELL", "wget -q -O- http://localhost:${var.port}/health/live || exit 1"]
      interval    = 10
      timeout     = 3
      retries     = 3
      startPeriod = 30
    }

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = "/ecs/${var.service_name}"
        awslogs-region        = data.aws_region.current.name
        awslogs-stream-prefix = "ecs"
      }
    }
  }])
}

Using the module:

# production/order-service/main.tf
module "order_service" {
  source = "../../modules/go-ecs-service"

  service_name  = "order-service-prod"
  image         = var.go_service_image
  cpu           = 512
  memory        = 1024
  desired_count = 3

  environment_vars = {
    ENVIRONMENT = "production"
    LOG_LEVEL   = "info"
    PORT        = "8080"
  }

  secrets = {
    DATABASE_URL = aws_secretsmanager_secret.db.arn
    JWT_SECRET   = aws_secretsmanager_secret.jwt.arn
  }
}

Workspaces for Environments

# Create separate state for each environment
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

# Switch and apply
terraform workspace select prod
terraform plan -var-file=prod.tfvars
terraform apply -var-file=prod.tfvars
# terraform.tfvars (per environment)

# dev.tfvars
environment     = "dev"
go_service_image = "123456789.dkr.ecr.us-east-1.amazonaws.com/order-service:latest"

# prod.tfvars
environment      = "prod"
go_service_image = "123456789.dkr.ecr.us-east-1.amazonaws.com/order-service:v1.2.3"

CDK for Terraform (CDKTF) in Go

CDKTF lets you write Terraform infrastructure in actual Go code — no HCL required:

go install github.com/hashicorp/terraform-cdk-go/cdktf
cdktf init --template=go --local
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"
)

type GoServiceStack struct {
    cdktf.TerraformStack
}

func NewGoServiceStack(scope constructs.Construct, name, env string) *GoServiceStack {
    stack := &GoServiceStack{}
    cdktf.NewTerraformStack_Override(stack, scope, jsii.String(name))

    // S3 backend for state
    cdktf.NewS3Backend(stack, &cdktf.S3BackendConfig{
        Bucket:       jsii.String("my-terraform-state"),
        Key:          jsii.Sprintf("services/order-service/%s/terraform.tfstate", env),
        Region:       jsii.String("us-east-1"),
        Encrypt:      jsii.Bool(true),
        DynamodbTable: jsii.String("terraform-locks"),
    })

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

    // ECR repository
    repo := aws.NewEcrRepository(stack, jsii.String("repo"), &aws.EcrRepositoryConfig{
        Name:                jsii.String("order-service-" + env),
        ImageTagMutability: jsii.String("IMMUTABLE"),
    })

    // RDS instance
    aws.NewDbInstance(stack, jsii.String("db"), &aws.DbInstanceConfig{
        Identifier:      jsii.String("order-service-" + env),
        Engine:          jsii.String("postgres"),
        EngineVersion:   jsii.String("16.1"),
        InstanceClass:   jsii.String("db.t3.micro"),
        AllocatedStorage: jsii.Number(20),
        StorageEncrypted: jsii.Bool(true),
        DbName:          jsii.String("orders"),
        Username:        jsii.String("app"),
        Password:        jsii.String(os.Getenv("DB_PASSWORD")),
    })

    // Output the ECR URL
    cdktf.NewTerraformOutput(stack, jsii.String("ecr_url"), &cdktf.TerraformOutputConfig{
        Value: repo.RepositoryUrl(),
    })

    return stack
}

func main() {
    app := cdktf.NewApp(nil)
    NewGoServiceStack(app, "order-service-dev", "dev")
    NewGoServiceStack(app, "order-service-prod", "prod")
    app.Synth()
}

CI/CD Integration

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
    paths: ['infra/**']
  pull_request:
    paths: ['infra/**']

jobs:
  terraform:
    runs-on: ubuntu-latest
    environment: production

    steps:
    - uses: actions/checkout@v4

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v3
      with:
        terraform_version: "1.7.0"

    - name: Terraform Init
      run: terraform init
      working-directory: infra/production

    - name: Terraform Format Check
      run: terraform fmt -check -recursive
      working-directory: infra/

    - name: Terraform Validate
      run: terraform validate
      working-directory: infra/production

    - name: Terraform Plan
      run: terraform plan -var-file=prod.tfvars -out=tfplan
      working-directory: infra/production
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

    # Only apply on merge to main
    - name: Terraform Apply
      if: github.ref == 'refs/heads/main' && github.event_name == 'push'
      run: terraform apply tfplan
      working-directory: infra/production
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Summary

  • Always use remote state (S3 + DynamoDB) — local state is deleted with terraform destroy and can’t be shared
  • Store secrets in Secrets Manager, not in Terraform state (state is not encrypted by default)
  • Use modules for reusable infrastructure patterns across services
  • Use terraform plan -out=tfplan + terraform apply tfplan in CI/CD to ensure plan is what gets applied
  • CDKTF lets you write infrastructure in Go — useful when you want type safety and code reuse across infra and application code
  • Validate before apply: terraform validate + terraform fmt -check in CI

Resources

Comments

👍 Was this article helpful?