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.mdEach 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_documentdata 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, andterraform plan, posting the plan output as a PR comment - On merge to main: run
terraform applyagainst 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.
Summary
- Module structure with separate networking, compute, and database modules is what separates intermediate from beginner Terraform
- Remote state with S3 + DynamoDB (AWS) or GCS (GCP) locking is non-negotiable — local state is not acceptable in a team
- Never commit the terraform.tfstate file to Git — add it to .gitignore
- Tag every resource with environment, project name, and ManagedBy — its absence is noticed
- Use aws_iam_policy_document for IAM policies rather than inline JSON strings
- Adding a GitHub Actions CI pipeline that runs terraform plan on pull requests turns this into a two-for-one portfolio build