Terraform in Practice #1 Project Design and Bootstrap: The Big Picture, Backend, and Common Tags
With nine parts of the basics series covering syntax and principles, it is time to build infrastructure you would actually use, from start to finish. This ten-part practice series has a single goal: to create everything one web service needs to operate — network, compute, database, secrets, permissions, and domain — as Terraform code. Syntax explanations are delegated to links back to the basics series; here, the focus is on the design decisions you actually end up making on the job.
What we build over the ten parts #
Let’s draw the finished picture first. This is the infrastructure of a fictional web service, myapp.
User
│
├── Route 53 (domain) ← #10
│ │
│ ├── CloudFront ── S3 (static assets) ← #5, #10
│ │
│ └── ALB (HTTPS) ← #4, #10
│ │
│ ├── Auto Scaling EC2 ← #3, #4
│ └── ECS Fargate ← #8
│ │
│ └── RDS (PostgreSQL) ← #5
│
├── VPC: public/private subnets × 2 AZs, NAT ← #2
├── Secrets: SSM, write-only arguments ← #6
├── IAM: instance role, GitHub OIDC ← #7
└── Env separation: dev/prod + module refactor ← #9The compute layer is deliberately built twice: EC2 Auto Scaling first, then a migration to ECS Fargate in #8. Real infrastructure is never finished in one pass — it evolves by swapping parts out — and doing that transition safely with Terraform is the most practical experience this series has to offer.
Talking about cost first #
Unlike the S3 bucket from the basics series, the resources in this series cost money. The big ones: the NAT Gateway (hourly + data processing), the ALB (hourly), RDS (instance hours), and ECS Fargate (vCPU and memory hours). The amounts are small, but leave them running and forgotten and you get the bill that suddenly spikes one day. So this series runs on two principles.
- When a session ends, remove everything with
terraform destroy. The code remains, so a single apply at the start of the next part restores it all. Tearing infrastructure down and bringing it back without hesitation is itself an exercise in feeling the power of IaC. - Sizes stay at lab minimum. t3.micro, db.t4g.micro, a single NAT — the smallest configuration possible, with the difference from production sizes expressed as variables in the environment separation of #9.
Bootstrap: the state bucket and the backend #
As laid out in Basics #9, the state bucket is created once, outside Terraform.
aws s3api create-bucket --bucket myapp-tfstate \
--region ap-northeast-2 \
--create-bucket-configuration LocationConstraint=ap-northeast-2
aws s3api put-bucket-versioning --bucket myapp-tfstate \
--versioning-configuration Status=EnabledCreate the project directory and put the skeleton files in place. We start with a single directory and refactor into modules in #9, once the code has grown enough. Stalling on the perfect structure up front teaches less than refactoring working code — and refactoring is also the order things actually happen in on real projects.
myapp-infra/
├── backend.tf # terraform block, backend, provider versions
├── providers.tf # provider configuration
├── variables.tf
├── outputs.tf
└── main.tf # where resources will accumulate# backend.tf
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "myapp-tfstate"
key = "myapp/dev/terraform.tfstate"
region = "ap-northeast-2"
use_lockfile = true
}
}Note that key is set to myapp/dev/ from the start. There is only dev for now, but this reserves the spot where myapp/prod/ will sit alongside it when prod arrives in #9.
default_tags: tagging every resource automatically #
The provider configuration gets one standard piece of real-world equipment.
# providers.tf
provider "aws" {
region = var.region
default_tags {
tags = {
Project = "myapp"
Env = var.env
ManagedBy = "terraform"
}
}
}default_tags automatically attaches these tags to every resource created through this provider. You no longer write tags on each resource, and no resource slips through untagged. Tags are not decoration — they are the primary axis of cost analysis (per-tag aggregation in Cost Explorer), and setting them up on day one is a very different amount of work from retrofitting hundreds of resources later. Per-resource tags merge on top of default_tags, so individual name tags (Name) still go on the resource itself.
# variables.tf
variable "region" {
type = string
default = "ap-northeast-2"
}
variable "env" {
type = string
default = "dev"
validation {
condition = contains(["dev", "prod"], var.env)
error_message = "env must be dev or prod."
}
}First run: the baseline of an empty infrastructure #
With not a single resource defined, run init and plan.
$ terraform init
Initializing the backend...
Successfully configured the backend "s3"!
$ terraform plan
No changes. Your infrastructure matches the configuration.“No changes” is this project’s starting line. The backend is attached to S3, locking is in place, and code and reality agree. Over the next nine parts we will pile resources into this directory, and every part will close with the same check: does the plan match the intent?
Recap #
What we covered in this post:
- The finished picture for the ten parts is a web service infrastructure with an ALB on a VPC, a transition from Auto Scaling EC2 to ECS Fargate, RDS and S3, secrets and IAM, and a domain with HTTPS
- NAT, ALB, RDS, and Fargate cost money. The rhythm is destroy after each session, then apply to restore at the start of the next part
- The state bucket is created outside Terraform, and the backend key is set to
myapp/dev/with environment separation in mind - The provider’s default_tags automatically attaches Project, Env, and ManagedBy tags to every resource. The primary axis of cost analysis goes in on day one
- We start with a single directory and refactor into modules in #9. Refactoring working code is the order real projects follow
In the next post (#2 VPC Network as Code), we build the foundation everything else stands on: the network. We will lay public and private subnets across two AZs and stand up the routing and the NAT gateway as code.