Building a Real-World Project with Terraform

7 min read

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
  }
}
Pro Tip: Store backend bootstrapping code separately from your main application infrastructure. This prevents circular dependencies where Terraform needs state infrastructure that has not been created yet.

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

Leave a Reply

Your email address will not be published. Required fields are marked *