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 destroyand 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 tfplanin 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 -checkin CI
Comments