Infrastructure as Code with Terraform: a practical starter for growing teams
A practical starter for infrastructure as code with Terraform: project layout, remote state, modules, secrets, CI and a correct minimal example.
On this page
- Why use infrastructure as code with Terraform?
- Terraform vs OpenTofu: what changed, and why it matters
- A practical project layout for a small team
- Remote state and locking
- Modules and variables
- Keeping secrets out of Terraform
- Running plan and apply in CI
- Detecting drift
- A minimal, correct example
- Common mistakes to avoid
- What to do next
Infrastructure as code (IaC) means defining your servers, networking and configuration in version-controlled files instead of changing things by hand through a cloud console, so every change is reviewed, repeatable and recorded. Terraform is the most widely used tool for this, using its own configuration language (HCL) to describe infrastructure across most major cloud providers from one workflow. This starter covers the layout, state, secrets and CI practices a small team needs to use it safely, plus a minimal working example.
If you're deciding whether to bring in outside help to set this up properly, see the pillar guide: DevOps as a service: what it is, what it costs, and when it beats hiring.
Why use infrastructure as code with Terraform?#
Manually configuring servers through a web console — sometimes called "ClickOps" — works until it doesn't: nobody remembers exactly what was changed last month, a new environment takes hours to recreate because half the steps live in someone's memory, and there's no review step before a change reaches production. IaC fixes this by making infrastructure a text file: changes go through the same pull request and review process as application code, and rebuilding an environment means running the same code again, not repeating a manual checklist.
The practical benefits compound once a team, not just one engineer, depends on the infrastructure:
- A staging environment that actually matches production, because both are built from the same code with different variable values, not two consoles that quietly drifted apart.
- A git history of every infrastructure change, so "who changed this and why" is a
git blameaway instead of a guess. - Faster, calmer disaster recovery, since rebuilding a lost server means running the existing configuration again rather than reconstructing it from memory under pressure — see backups that actually restore for how this fits into a wider recovery plan.
- A real review step, since a pull request against a
.tffile can be reviewed by a second engineer before it changes anything, the same as application code.
Terraform vs OpenTofu: what changed, and why it matters#
In August 2023, HashiCorp announced it was relicensing Terraform from the open-source Mozilla Public License 2.0 (MPL 2.0) to the Business Source License (BUSL), citing concern about vendors building competing commercial products on top of its open-source work. The following month, the Linux Foundation launched OpenTofu, an open-source fork taken from the last MPL-2.0-licensed Terraform codebase, backed at launch by more than 140 organisations. OpenTofu is designed to stay backward-compatible with Terraform configuration and reached general availability in January 2024 under community, rather than single-vendor, governance.
Day to day, the two tools look and behave very similarly, since OpenTofu started as a direct fork. The practical differences are licensing (what you're allowed to do with the source and any commercial derivative of it) and governance (who decides the roadmap). Most of what follows in this guide applies to both; check each project's own release notes for the current state of any specific feature, since they will diverge further over time.
A practical project layout for a small team#
HashiCorp's own standard module structure recommends keeping it simple at the root:
main.tf— resources and module callsvariables.tf— input variable declarationsoutputs.tf— values exposed to other configurationsterraform.tfvars— actual values (kept out of git if they contain secrets)modules/web_server/— a reusable, self-contained module, itself with its ownmain.tf,variables.tfandoutputs.tf
Only the root module is strictly required — everything else is a convention that keeps a project navigable once more than one person works on it. Add a .gitignore covering .terraform/ (the local provider cache) and *.tfstate* (state files) from day one.
For separating environments (staging vs production), most small teams are better served by a separate root configuration per environment — a staging/ and production/ directory, each with its own backend and variable values, calling the same shared modules — rather than Terraform workspaces, which share a module but can make it too easy to apply a staging-sized change against production by mistake.
Remote state and locking#
Terraform's state file is its record of what it last created, used to work out what needs to change on the next run. Left on one person's laptop, it becomes a single point of failure and a merge conflict waiting to happen. Storing it in a remote backend — Amazon S3, Azure Blob Storage and Google Cloud Storage are common choices — lets a team share it safely and, critically, adds state locking: "if supported by your backend, Terraform will lock your state for all operations that could write state," preventing two applies from corrupting it at the same time. Not all backends support locking, so check before you pick one.
Modules and variables#
A module is just a directory of Terraform files that can be called with a source and a set of inputs, which keeps repeated infrastructure (a standard web server, a standard database) defined once instead of copy-pasted:
module "web_server" {
source = "./modules/web_server"
environment = var.environment
instance_type = var.instance_type
}Variables should have a description so the next person (including future you) knows what each one is for, and a type to catch mistakes early. Reserve default values for things that are genuinely safe to default, like a region, not for anything environment-specific like an account ID.
Keeping secrets out of Terraform#
Mark anything sensitive — a database password, an API key — with sensitive = true on its variable declaration, which stops Terraform printing the value in plan or apply output. This is not the same as keeping it out of the state file: HashiCorp's own documentation is explicit that "Terraform still stores the values of sensitive variables in your state," so the state file itself needs the same access control as any other secret store. In practice, that means: never commit a .tfvars file containing real secret values, supply secrets via CI-provided environment variables or a secrets manager at run time, and keep your remote state backend access-controlled and encrypted at rest.
Running plan and apply in CI#
HashiCorp's own CI/CD automation guidance recommends a specific, non-interactive sequence rather than running apply directly:
terraform init -input=false
terraform plan -out=tfplan -input=false
terraform apply -input=false tfplanApplying a saved plan file (rather than re-running apply on its own) guarantees the change actually applied matches the one that was reviewed. Combine this with a remote backend that supports locking so two pipeline runs — or a pipeline run and someone's laptop — can't collide, and treat manual review of the plan output as part of the process even once it's automated. For the wider pipeline this sits inside, see CI/CD pipeline best practices.
Detecting drift#
Drift is what HashiCorp calls it when real infrastructure no longer matches your Terraform configuration — usually because someone made a manual change outside Terraform, or a cloud provider changed something automatically. The next terraform plan surfaces drift as a diff between state and reality. Because a surprise diff right before a release is a bad time to discover drift, some teams run a scheduled, non-applying plan purely to catch it early, particularly for infrastructure that container platforms or other automation might also touch — see do you really need Kubernetes? if you're weighing up how much of your stack should be under an orchestrator versus plain Terraform-managed servers.
A minimal, correct example#
This is illustrative, not tied to any specific provider you must use — swap the provider and resource for your own:
# main.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "acme-terraform-state"
key = "production/web/terraform.tfstate"
region = "ap-southeast-2"
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = "${var.environment}-web"
Environment = var.environment
}
}# variables.tf
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "ap-southeast-2"
}
variable "environment" {
description = "Deployment environment name, e.g. staging or production"
type = string
}
variable "ami_id" {
description = "AMI ID to launch (region and provider specific)"
type = string
}
variable "instance_type" {
description = "Instance size for the web server"
type = string
default = "t3.micro"
}
variable "db_password" {
description = "Database password, supplied via TF_VAR_db_password or a secrets manager — never committed"
type = string
sensitive = true
}The use_lockfile = true argument in the S3 backend configuration enables native state locking directly on the backend, without needing a separate DynamoDB table for that purpose.
Common mistakes to avoid#
Most Terraform incidents trace back to one of a small set of avoidable mistakes:
- Committing
.tfstatefiles or.tfvarsfiles containing real secrets to version control. - Using local-only state with no locking, so two people (or a person and a CI run) apply at the same time.
- One enormous
main.tfcovering unrelated infrastructure, instead of modules with a clear boundary. - Hardcoding values that should be variables — region, account IDs, AMI IDs — making the code hard to reuse for a second environment.
- Manual changes in the cloud console "just this once," which is how drift starts.
- Not pinning provider versions, so a routine
initon a new machine pulls in unexpected provider behaviour. - Running
applywithout reading the plan output first, in automation as much as locally.
What to do next#
Setting this up properly the first time — remote state, locking, a sensible module layout, secrets handled correctly — is easier than untangling it later once several people have made manual changes on top of it. If you'd rather have this reviewed or built by people who do it daily, managed DevOps covers infrastructure as code as part of ongoing support, and CI/CD automation covers wiring it into your pipeline.
Frequently asked questions
Should I use Terraform or OpenTofu?
Both work similarly day to day, since OpenTofu forked from Terraform's last MPL-2.0 codebase and aims to stay compatible. The practical difference is licensing and governance: Terraform is Business Source License (HashiCorp-controlled), OpenTofu is open source under Linux Foundation governance. Check each project's current release notes before committing, since they diverge over time.
What is Terraform state, and why does it need to be remote?
State is Terraform's record of what it last created, so it can work out what to change next. Keeping it remote (for example, in an S3 bucket) rather than on one person's laptop lets a team share it safely, and remote backends can add locking so two applies don't collide.
How do I keep secrets out of Terraform?
Mark sensitive variables with sensitive = true so Terraform doesn't print them in output, keep files containing real secret values out of version control, and prefer pulling secrets from a secrets manager or CI-provided environment variables at run time. Note that sensitive values are still written to the state file, so the state itself must be protected too.
What is configuration drift in Terraform?
Drift is when real infrastructure no longer matches your Terraform configuration, usually because someone changed something manually outside Terraform. Running terraform plan surfaces drift as a diff; some teams also run scheduled, non-applying plans purely to detect it before it causes a confusing failure later.
Can Terraform run safely in a CI/CD pipeline?
Yes — the common pattern is terraform init, then terraform plan saved to a file, then a separate terraform apply of that exact plan, using a remote backend with locking so concurrent runs can't collide. Manual review of the plan output before apply is still recommended, even in an automated pipeline.
Sources
- HashiCorp Adopts Business Source License — HashiCorp — accessed 18 September 2026
- Linux Foundation Launches OpenTofu: A New Open Source Alternative to Terraform — The Linux Foundation — accessed 18 September 2026
- Purpose of Terraform State (Remote State) — HashiCorp Developer — accessed 18 September 2026
- State Locking — HashiCorp Developer — accessed 18 September 2026
- S3 Backend — HashiCorp Developer — accessed 18 September 2026
- Standard Module Structure — HashiCorp Developer — accessed 18 September 2026
- Input Variables (sensitive values) — HashiCorp Developer — accessed 18 September 2026
- Automate Terraform with CI/CD — HashiCorp Developer — accessed 18 September 2026
- Manage Resource Drift — HashiCorp Developer — accessed 18 September 2026
Facts in this article were last checked on 18 September 2026.
Inventure Engineering Team
Engineers at Inventure Technologies who build, host and run software for clients in Nepal and Australia. We write about what we do every day.
Keep reading
DevOps as a service: what it is, what it costs, and when it beats hiring
What managed DevOps includes, how pricing and engagement models work, and a cost framework for comparing DevOps as a service with hiring in-house.
Read articleManaged DevOps vs an in-house team vs freelancers: cost and risk compared
A fair, cost-and-risk comparison of managed DevOps services, an in-house team and freelancers, so you can match the model to your stage of growth.
Read articleCI/CD pipeline best practices: from git push to production
The CI/CD practices that actually matter: trunk-based development, build-once artefacts, safe migrations, and a working GitHub Actions example.
Read articleWant engineers who handle this for you?
We build, host and run software for teams in Nepal and Australia — with dedicated support on every plan.