Building a Real-World Project with Terraform
Building a Real-World Project with Terraform
Terraform project work becomes far more valuable when you move beyond toy examples and design infrastructure that teams can actually deploy, review, secure, and scale. In this article, we will build a production-minded setup using Terraform modules, remote state, environment separation, validation, and automated delivery practices that mirror real-world engineering workflows.
Hook & Key Takeaways
If you have only used Terraform to spin up a single VM or bucket, this guide shows how to evolve that into a maintainable platform foundation.
- Design a reusable Terraform project structure for multiple environments.
- Use remote state and locking to support team collaboration safely.
- Build modules for networking, compute, and application layers.
- Integrate security checks, formatting, validation, and CI/CD workflows.
- Apply operational patterns that reduce drift and deployment risk.
Why a Terraform Project Needs Real-World Structure
A serious Terraform codebase is not just a collection of resource blocks. It is a system for managing infrastructure lifecycle, team ownership, environment isolation, governance, and repeatability. In practice, that means organizing code so developers can deploy changes with confidence while operations teams maintain visibility and control.
For example, if your application layer includes a Python API, you may already be thinking in terms of modular architecture and integration boundaries, much like the patterns discussed in this Flask integration guide. Terraform benefits from the same discipline: clear separation, predictable interfaces, and environment-aware configuration.
Architecture of a Real-World Terraform Project
Let us model a practical deployment on AWS with these layers:
- Remote state in S3 with DynamoDB locking
- Reusable modules for VPC, security groups, EC2, and IAM
- Separate environments such as dev, staging, and production
- Variables and outputs to expose only what consumers need
- CI/CD checks for formatting, validation, and planning
| Layer | Purpose | Terraform Scope |
|---|---|---|
| Backend | Shared state and locking | S3, DynamoDB |
| Network | Routing and segmentation | VPC, subnets, route tables |
| Security | Controlled access | IAM, security groups |
| Compute | Application runtime | EC2, autoscaling or containers |
| Automation | Safe delivery workflows | Plan, apply, policy checks |
Recommended Terraform Project Directory Layout
A maintainable structure keeps reusable logic inside modules and environment-specific instantiation inside dedicated folders.
terraform-project/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── security_group/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── ec2/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── dev/
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── variables.tf
│ ├── staging/
│ └── production/
├── versions.tf
├── providers.tf
└── README.md
Why This Terraform Project Layout Works
- Modules contain reusable infrastructure definitions.
- Environments declare how each stage consumes those modules.
- Version pinning reduces surprises across developer machines and pipelines.
- Backend isolation avoids state collisions.
Setting Up Remote State for a Terraform Project
State is the heart of Terraform. In a team setting, local state quickly becomes dangerous because it is easy to lose, duplicate, or corrupt. A remote backend enables locking, collaboration, auditability, and recovery.
Create the Backend Resources
resource "aws_s3_bucket" "terraform_state" {
bucket = "company-terraform-state-prod"
lifecycle {
prevent_destroy = true
}
tags = {
Name = "terraform-state"
Environment = "shared"
}
}
resource "aws_s3_bucket_versioning" "terraform_state_versioning" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "terraform-locks"
}
}
Configure the Backend
terraform {
backend "s3" {
bucket = "company-terraform-state-prod"
key = "dev/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Building Reusable Modules in a Terraform Project
Modules are where a Terraform project begins to feel like software engineering instead of one-off provisioning. A module should expose a clean interface, hide unnecessary internals, and remain focused on a single concern.
VPC Module Example
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = var.name
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.name}-public-${count.index + 1}"
}
}
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
Security Group Module Example
resource "aws_security_group" "this" {
name = var.name
description = var.description
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = var.allowed_http_cidrs
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_ssh_cidrs
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Deploying an Environment with the Terraform Project
Each environment should call modules with its own values rather than duplicating resource definitions. This approach keeps drift low and updates consistent.
module "vpc" {
source = "../../modules/vpc"
name = "dev-vpc"
vpc_cidr = "10.10.0.0/16"
public_subnet_cidrs = ["10.10.1.0/24", "10.10.2.0/24"]
availability_zones = ["us-east-1a", "us-east-1b"]
}
module "web_sg" {
source = "../../modules/security_group"
name = "dev-web-sg"
description = "Web security group"
vpc_id = module.vpc.vpc_id
allowed_http_cidrs = ["0.0.0.0/0"]
allowed_ssh_cidrs = ["203.0.113.10/32"]
}
module "web" {
source = "../../modules/ec2"
instance_name = "dev-web-1"
ami_id = var.ami_id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0]
security_group_ids = [module.web_sg.security_group_id]
}
Variables, Outputs, and State Hygiene in a Terraform Project
Use Variables Deliberately
Not everything needs to be configurable. Too many variables make modules harder to understand and test. Start with the settings that truly vary by environment: CIDR blocks, instance sizes, AMI IDs, region details, and access lists.
Protect Sensitive Values
variable "db_password" {
description = "Database password"
type = string
sensitive = true
}
Secrets should usually come from a secret manager or CI/CD injection rather than plain tfvars files committed to source control. If your stack provisions application backends such as NestJS services, infrastructure hardening should align with application-level controls like the recommendations in this NestJS security guide.
Use Outputs Sparingly
Outputs should expose only the values other modules or deployment systems need. Overexposing infrastructure internals creates tight coupling and increases accidental dependence.
Validation and Testing for a Terraform Project
A robust Terraform project should fail early before infrastructure changes reach production.
Core CLI Checks
terraform fmt -recursive
terraform init
terraform validate
terraform plan
Example CI Workflow
name: terraform-ci
on:
pull_request:
push:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Init
working-directory: environments/dev
run: terraform init
- name: Terraform Validate
working-directory: environments/dev
run: terraform validate
- name: Terraform Plan
working-directory: environments/dev
run: terraform plan -input=false
Security Best Practices for a Terraform Project
Principle of Least Privilege
Terraform should authenticate with a role that can perform only the required infrastructure actions. Avoid broad administrative credentials, especially in automated pipelines.
Prevent Accidental Destruction
resource "aws_db_instance" "primary" {
identifier = "prod-db"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
skip_final_snapshot = false
deletion_protection = true
}
Scan for Misconfigurations
Add policy and scanning tools such as Checkov, tfsec, or OPA-based validation. These help catch open security groups, missing encryption, and unsafe IAM permissions before deployment.
Managing Drift and Change in a Terraform Project
Infrastructure drift happens when resources are changed outside Terraform. Over time, drift undermines trust in plans and increases rollout risk.
Reduce Drift with Process
- Restrict manual changes in cloud consoles.
- Run scheduled plans to detect unexpected differences.
- Use import and refactoring carefully when adopting existing infrastructure.
- Document ownership boundaries between teams.
Apply Changes Safely
Review plans in pull requests, require approvals for production, and separate plan from apply in your pipeline. Many teams also store plan artifacts so reviewers can validate exactly what will be executed.
Common Pitfalls in a Terraform Project
- Putting all resources into a single flat root module
- Using local state for collaborative environments
- Over-parameterizing modules
- Hardcoding secrets in variables or tfvars files
- Skipping version constraints for providers and Terraform
- Applying directly to production without reviewed plans
Production Checklist for a Terraform Project
| Checklist Item | Status Goal |
|---|---|
| Remote state enabled | Required |
| State locking configured | Required |
| Modules separated by concern | Required |
| CI validation pipeline | Required |
| Secrets externalized | Required |
| Policy and security scans | Strongly recommended |
Conclusion
A successful Terraform project is not measured by how quickly it provisions resources, but by how reliably teams can evolve infrastructure over time. By using remote state, modular design, validation pipelines, secret hygiene, and environment separation, you create an infrastructure platform that supports real delivery instead of fragile demos. Once these patterns are in place, Terraform becomes a dependable engineering tool for shipping production systems with confidence.
FAQ: Terraform Project Essentials
1. What is the best structure for a Terraform project?
The best structure separates reusable modules from environment-specific configurations. This keeps code maintainable, promotes reuse, and reduces duplication across dev, staging, and production.
2. Why should a Terraform project use remote state?
Remote state enables collaboration, locking, versioning, and safer recovery. It also reduces the risk of conflicting updates when multiple engineers work on the same infrastructure.
3. How do I secure a Terraform project in production?
Use least-privilege IAM roles, keep secrets out of code, enable policy and security scans, review plans before apply, and protect critical resources with lifecycle and deletion safeguards.
1 comment