How I use Terraform on my projects: a boring AWS setup that holds up
Most of what I write here is about LLM features: the contract around the model, evals, keeping it healthy in production. But every one of those features runs somewhere. A Node.js service needs a place to live. Postgres needs backups. The Anthropic and OpenAI keys need a home that isn't a .env file on someone's laptop.
For that part I use Terraform on AWS, and I keep it deliberately boring. This post covers the setup I reuse from project to project: where state lives, how the repo is laid out, what goes in a module, what stays out of Terraform, and how changes reach production. The worked example is the smallest real project I have: the site you're reading.
Why Terraform, even for small projects
The honest argument for infrastructure as code on a small project isn't scale. It's memory.
Six months after launch, nobody remembers why the security group allows port 5432 from that one CIDR, or which IAM policy the worker actually needs. If the infrastructure lives in the console, that knowledge lives in someone's head. If it lives in Terraform, it lives in a pull request with a description and a reviewer.
A few other reasons I stick with it:
- Reproducibility. A staging environment that matches production is a
terraform applyaway, not a day of clicking. - Reviewable changes.
terraform planshows exactly what will change before it happens. That's the single most useful safety feature in cloud work. - One tool for most of the stack. Terraform works the same way against AWS, Cloudflare, GitHub and dozens of other providers.
I've looked at CDK and Pulumi. They're good tools, and if a team is already deep in TypeScript infrastructure I won't fight it. But HCL's limits are a feature for me: it's hard to write clever infrastructure code, and clever infrastructure code is what breaks at 2 a.m.
A real example: this blog
This site is a static Next.js export served from a private S3 bucket through CloudFront. DNS stays in Cloudflare. Everything on the AWS side is about 200 lines of Terraform in an infra/ folder next to the app.
A few details from that code are worth showing, because they're the same decisions I make on bigger projects.
The bucket is private. CloudFront reads it through Origin Access Control, and the bucket policy only allows the one distribution:
data "aws_iam_policy_document" "site" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
No public bucket, no S3 website endpoint. A private bucket answers 403 for missing keys, so the distribution maps 403 to the site's 404 page.
Chicken-and-egg problems are solved in stages, not by hand-editing. CloudFront needs an issued ACM certificate, and the certificate needs DNS validation records in Cloudflare. So the first apply creates only the certificate, a variable flips, and the second apply creates the rest:
# Stage 1 creates only the ACM certificate. Enable once its validation
# records are in Cloudflare; CloudFront needs an issued certificate.
variable "enable_site" {
type = bool
default = false
}
The validation records come out of a Terraform output, so nobody copies them from the console.
Small edge logic lives in a CloudFront Function, also in Terraform. It redirects www to the apex and maps /blog/some-post/ to index.html. It's twenty lines of JavaScript inside a heredoc, versioned with everything else.
Remote state, set up once
Terraform state is the source of truth for what exists. It never goes in git and never lives only on one machine. It goes in its own S3 bucket, one per project:
terraform {
backend "s3" {
bucket = "myproject-tfstate"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
use_lockfile = true uses S3's native locking, so two people (or two CI jobs) can't apply at the same time. Older setups needed a DynamoDB table for this; recent Terraform versions don't.
The state bucket has versioning on, so a corrupted or mistaken state file can be rolled back. It's the one thing created outside Terraform: on a small project, two CLI commands documented at the top of backend.tf; on a bigger one, a tiny bootstrap/ stack that also creates the CI role and a budget alert.
Tags on every resource
The provider block sets tags that land on every resource automatically:
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = "nicolasduque.com"
ManagedBy = "terraform"
}
}
}
This costs nothing and pays off the first time you open Cost Explorer and want to know which project is spending what. ManagedBy = "terraform" also tells anyone browsing the console: don't edit this by hand.
When the project grows: layout and environments
A static site needs one root module and one environment. A product with an API, a database and staging needs more structure. This is the layout I move to:
infra/
├── bootstrap/ # one-time: state bucket, CI role, budget
├── modules/
│ ├── network/ # VPC, subnets, NAT
│ ├── service/ # ECS Fargate service + ALB target
│ ├── database/ # RDS Postgres
│ └── secrets/ # Secrets Manager entries (no values)
└── envs/
├── staging/
└── production/
Two decisions here are worth explaining.
One directory per environment, not workspaces. Workspaces look convenient, but they make it too easy to run apply against the wrong environment, and they hide the differences between environments inside conditionals. With separate directories, envs/production/main.tf is the literal description of production. The small amount of duplication is worth the clarity.
Modules are small and mine. Each module wraps one concern with the defaults I want: encryption on, public access off, tags applied. I read community modules for reference, but I rarely depend on large ones directly. A 40-line module I understand beats a 2,000-line one with 150 variables.
A typical service then reads like this:
module "api" {
source = "../../modules/service"
name = "api"
cluster_id = module.network.ecs_cluster_id
subnet_ids = module.network.private_subnet_ids
cpu = 512
memory = 1024
desired_count = 2
port = 3000
environment = {
NODE_ENV = "production"
LOG_LEVEL = "info"
}
secrets = {
DATABASE_URL = module.secrets.arns["database_url"]
ANTHROPIC_API_KEY = module.secrets.arns["anthropic_api_key"]
}
}
Inside the module, the task definition references those secrets by ARN, and the task's execution role can read only those ARNs. The container receives them as environment variables at startup. The values never touch Terraform code, logs or the repo.
That matters for LLM work in particular. Model API keys are expensive when leaked, and they tend to spread across services. Giving each service scoped access to exactly the keys it needs makes rotation and auditing straightforward.
What I keep out of Terraform
Knowing what not to manage with Terraform is half of using it well.
- Secret values. Terraform creates the Secrets Manager entry; the value is set separately. Anything Terraform manages ends up in the state file, so I don't give it plaintext secrets.
- Application deploys. Terraform defines where the app runs; shipping a new version is a separate step. On this blog that's a short script that reads the bucket and distribution ID from
terraform output, syncs the build to S3 and invalidates CloudFront. On an ECS service, CI updates the service with a new image, andlifecycle { ignore_changes = [task_definition] }keeps Terraform from fighting it. If every deploy ranterraform apply, a routine release could carry an unreviewed infrastructure change with it. - Data. Terraform creates the database; migrations are the application's job.
Protecting the things that hurt to lose
Some resources should never be destroyed by accident, whatever a plan says:
resource "aws_db_instance" "main" {
# ...
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "${var.name}-final"
backup_retention_period = 7
lifecycle {
prevent_destroy = true
}
}
prevent_destroy makes Terraform refuse to plan the deletion at all. deletion_protection adds the same guard on the AWS side. Belt and suspenders, because a dropped production database is the one mistake you can't fix with a revert.
The pipeline: plan on PR, apply on merge
On a one-person static site, I run plan and apply myself and read the plan before typing yes. As soon as there's a team or a client, changes go through CI instead:
- On pull request:
terraform fmt -check,terraform validate,tflint, thenterraform plan, with the plan posted as a comment so the reviewer sees exactly what will change. - On merge to main: apply to staging automatically.
- Production: apply behind a manual approval step.
CI authenticates to AWS with OIDC, not stored access keys. The CI provider presents a short-lived token, and AWS exchanges it for temporary credentials on a role scoped to that repository. No long-lived keys to rotate or leak.
# .github/workflows/infra.yml (excerpt)
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init && terraform plan -out=tfplan
working-directory: infra/envs/staging
The same pattern works in GitLab CI; only the OIDC wiring changes.
Drift and cost
Someone will eventually change something in the console during an incident. That's fine; incidents are allowed to be messy. What matters is noticing afterward. A scheduled job that runs terraform plan once a day and flags a non-empty plan catches it. Then you either codify the change or let Terraform put things back.
Terraform also makes it easy to create expensive things. Two cheap habits:
- An AWS Budget with email alerts from day one.
- Reading the plan for the usual surprises before approving: NAT gateways, oversized RDS instances, anything "provisioned". On this blog,
PriceClass_100on CloudFront is a one-line decision that keeps the bill at almost nothing.
The checklist I start every project with
- Create the state bucket (versioned, encrypted, private) and point the S3 backend at it with
use_lockfile = true. - Set
default_tagson the provider. - Keep buckets private and let CloudFront or the app reach them through scoped policies.
- Write small modules for the concerns you actually have.
- Put
prevent_destroyanddeletion_protectionon anything with data. - Keep secret values and app deploys out of Terraform.
- Once there's more than one person: plan on PR, apply on merge, OIDC for CI, a manual gate for production.
- Add a budget alert and a daily drift check.
None of this is exotic. That's the point. The infrastructure should be the least interesting part of the project, so the attention can go to the product and, in my case lately, to the LLM features running on top of it. How I keep those healthy once they're deployed is in Building an LLM feature, part 5: ship it and keep it healthy.
More in Backend, security & infra · All writing