How to Build a Terraform Portfolio Project

Terraform is one of the most commonly required tools for cloud engineering, DevOps, and SRE roles. But a Terraform portfolio project is easy to get wrong — a single flat main.tf file with everything hardcoded signals a beginner, even if the infrastructure it provisions is complex. This guide explains how to build a Terraform project that demonstrates real production thinking.

What to provision

The infrastructure you provision matters less than how you structure the Terraform code. That said, aim for something with at least three layers of concern:

  • Networking — a VPC with public and private subnets, routing, and a NAT gateway
  • Compute — an auto-scaling group of EC2 instances (AWS) or a managed instance group (GCP), a container service, or a serverless function
  • Data — a database (RDS, Cloud SQL, or DynamoDB), or a storage bucket with appropriate access controls

A three-tier web application (load balancer, application servers, database) provisioned entirely in Terraform is a solid project for junior and mid-level roles. It covers enough surface area to demonstrate meaningful skill without being so large it becomes unfinishable.

Module structure: the most important part

A flat Terraform file — everything in one main.tf — is the clearest signal of a beginner. Real Terraform usage at companies means modules. Your project should be structured into at least three reusable modules:

terraform/
├── modules/
│   ├── networking/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── compute/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── database/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       └── terraform.tfvars
└── README.md

Each module should have its own variables.tf (inputs), outputs.tf (what it exposes to other modules), and main.tf (the resource definitions). The environment directories call the modules with environment-specific variable values.

This structure means the same module code runs in both dev and prod — the only difference is the variable values passed in. This is the production pattern. Explain it in your README.

Remote state with locking

Local Terraform state (the default terraform.tfstate file on your machine) is not acceptable in a team environment. Remote state stored in S3 + DynamoDB (AWS) or GCS (GCP) with locking is the standard. Even for a solo portfolio project, configuring remote state demonstrates that you understand why it matters.

Configure a backend block:

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "dev/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

The DynamoDB table provides locking so two simultaneous terraform apply runs cannot corrupt the state. The encrypt = true enables server-side encryption of the state file. Both are worth explaining in your README — they are not obvious to beginners.

Do not commit the terraform.tfstate file to your repository. Add it to .gitignore. Committing state files is a security risk (they contain sensitive resource details) and a sign of beginner practice.

Variables, validation, and defaults

Every configurable value should be a variable, not hardcoded in the resource definition. This means:

  • Region, environment name, and resource naming prefixes are variables
  • Instance types, replica counts, and sizes are variables with sensible defaults
  • CIDR ranges for VPC and subnets are variables

Add Terraform variable validation for inputs with restricted valid values:

variable "environment" {
  type        = string
  description = "Deployment environment: dev, staging, or prod"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

Variable validation prevents someone from accidentally deploying with an invalid environment name and produces a clear error message. It is a small detail that signals care in your code.

Resource tagging

Every resource should be tagged with at minimum: environment, project name, and managed-by (Terraform). This is standard at every company using cloud infrastructure and hiring managers notice its absence.

A clean approach is defining a local.common_tags map and merging it with any resource-specific tags:

locals {
  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "terraform"
  }
}

resource "aws_instance" "web" {
  # ...
  tags = merge(local.common_tags, {
    Name = "${var.project_name}-web-${var.environment}"
  })
}

Explain in your README why tagging matters: cost allocation, security audit, and identifying orphaned resources.

IAM in Terraform

Provision IAM roles and policies in Terraform rather than through the console. This means:

  • EC2 instance profiles with task-specific roles — not AdministratorAccess
  • IAM policies defined as Terraform aws_iam_policy_document data sources rather than raw JSON strings
  • Service accounts (GCP) or instance profiles (AWS) attached to compute resources

Using aws_iam_policy_document instead of inline JSON is a common interview discussion point. It produces more readable Terraform and catches JSON syntax errors at plan time rather than apply time.

Adding a CI pipeline for Terraform

Adding a GitHub Actions workflow for Terraform elevates this project significantly:

  • On pull requests: run terraform fmt --check, terraform validate, and terraform plan, posting the plan output as a PR comment
  • On merge to main: run terraform apply against the dev environment automatically
  • For prod: require a manual approval before applying

This makes the repository a Terraform CI/CD showcase as well as an infrastructure showcase — two portfolio project categories in one build.

What to document in the README

  • The module structure and why you chose it
  • How to deploy to dev and prod from scratch (step-by-step bootstrap instructions)
  • The remote state configuration and what the locking mechanism prevents
  • The IAM design for compute resources
  • What you would add in a real production use — for example: Sentinel policy checks, Terraform Cloud or Atlantis for team-based workflows, module versioning with a private registry

For guidance on organising your repository folder structure, see cloud portfolio GitHub repository structure.