Skip to main content

Terraform for Small Teams: IaC Without the Overhead

·1839 words·9 mins
Author
Maksim P.
DevOps Engineer / SRE

TL;DR
#

  • Use Terraform (or OpenTofu) for anything you recreate more than once: networks, databases, clusters, buckets, DNS.
  • Remote state is not optional — set it up before you have a reason to regret skipping it.
  • Keep modules flat and boring — one repo, one state per environment, no clever abstractions until you feel real pain.
  • Run Terraform in CI — a plan on PR, apply on merge. Never from a laptop in prod.

Who this guide is for
#

This is for teams of 3–10 engineers that want repeatable infrastructure without a full platform engineering function. You’re probably already using a cloud provider, maybe clicking around the console, and you’ve heard you should be doing “IaC” but aren’t sure where to start or how far to take it.

The goal is a setup that works, doesn’t require a Terraform expert to operate, and can grow with you.

Why bother with Terraform at this stage
#

The honest answer: because console-clicking doesn’t scale and “it works on my account” is not documentation.

Terraform gives you:

  • Repeatability: recreate staging that looks like prod in minutes.
  • Auditability: infrastructure changes go through the same review process as code.
  • Safety: plan before apply; review before destroy.
  • Handover: new team members can understand what exists and why.

Even a small team benefits from these — especially when the person who set up the database three years ago has left.

Core concepts (fast recap)
#

If you already know Terraform, skip this.

  • Provider: the plugin that talks to a cloud API (AWS, GCP, Azure, Hetzner…).
  • Resource: a thing you’re managing (a database, a bucket, a DNS record).
  • State: a file (.tfstate) that tracks what Terraform manages. This is precious.
  • Plan: Terraform compares state to your config and shows what it will change.
  • Apply: executes the plan. Changes happen.

The mental model: Terraform holds a map of the world. You describe what the world should look like. It figures out the diff and makes it so.

Remote state: do this first
#

By default, state is stored locally. That’s fine for one person experimenting. It’s a problem the moment two people share infrastructure or you lose your laptop.

Set up remote state before you share the repo.

Use your cloud provider’s object storage:

  • AWS: S3 bucket — locking is built into the backend now, no extra table needed.
  • GCP: GCS bucket.
  • Azure: Azure Storage.
  • Hetzner / other: Terraform Cloud or a self-managed S3-compatible backend works.

A minimal S3 backend block:

terraform {
  backend "s3" {
    bucket       = "your-company-tfstate"
    key          = "prod/terraform.tfstate"
    region       = "eu-central-1"
    use_lockfile = true
    encrypt      = true
  }
}

use_lockfile = true is S3-native state locking, available since Terraform 1.10. Terraform writes a small <key>.tflock object next to your state using a conditional write, so a second apply fails fast instead of trampling the first. If you have older docs (or an older setup) telling you to create a DynamoDB table and set dynamodb_table, that path still works but is deprecated as of Terraform 1.11 and will be removed — it is one less resource to provision, pay for, and grant IAM access to. Migrating is a two-step job: set use_lockfile = true alongside the existing dynamodb_table, run once so both are in play, then drop dynamodb_table and delete the table.

Rules:

  • One bucket, one key per environment (staging/terraform.tfstate, prod/terraform.tfstate).
  • Enable versioning on the bucket so you can recover from bad applies.
  • Encrypt the state (it often contains secrets).
  • Never commit .tfstate to git. Add it to .gitignore.

Project structure for a small team
#

Keep it simple. One common pattern that works:

terraform/
  staging/
    main.tf
    variables.tf
    outputs.tf
    backend.tf
  prod/
    main.tf
    variables.tf
    outputs.tf
    backend.tf
  modules/
    database/
    networking/

Why separate directories per environment instead of workspaces?

  • Each environment has its own state file. A terraform destroy in staging can’t touch prod.
  • Easier to review: you see exactly what’s different between environments.
  • Easier for CI: staging and prod have independent pipelines.

Workspaces are fine for very simple cases, but directories are less surprising.

What to manage with Terraform (and what not to)
#

Good fits for Terraform
#

  • Networks, subnets, security groups, firewall rules.
  • Managed databases (Postgres, MySQL), Redis.
  • Object storage buckets and policies.
  • DNS records.
  • Kubernetes clusters (the cluster itself, not what runs inside it).
  • IAM roles, service accounts, access policies.
  • CDN, load balancers, TLS certificates.

Not worth Terraforming early on
#

  • Every console setting you’ll change once.
  • Application config and feature flags (use your app’s config system).
  • Kubernetes workloads (use Helm/Kustomize instead).
  • Short-lived resources you create and destroy constantly.

The rule: if recreating it from scratch would take more than 10 minutes to get right, put it in Terraform.

CI/CD integration: never apply from a laptop
#

The single biggest improvement you can make to your Terraform workflow is running it in CI.

Minimum setup:

  • On pull request: run terraform plan and post the output as a comment.
  • On merge to main: run terraform apply automatically (for staging) or with manual approval (for prod).

This means:

  • Changes are reviewed before they happen.
  • There’s an audit trail of who applied what and when.
  • Nobody applies from a personal terminal with local credentials.

GitHub Actions example (minimal):

name: Terraform

on:
  pull_request:
    paths:
      - 'terraform/**'
  push:
    branches: [main]
    paths:
      - 'terraform/**'

permissions:
  contents: read
  id-token: write        # required to mint the OIDC token for AWS
  pull-requests: write   # required to post the plan as a PR comment

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: terraform/staging

    steps:
      - uses: actions/checkout@v4

      # Without this step `terraform init` fails with
      # "No valid credential sources found" — the backend needs
      # credentials before it can even read state.
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
          aws-region: eu-central-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.13'   # >= 1.10 for use_lockfile

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        if: github.event_name == 'pull_request'
        run: terraform plan -no-color -out=tfplan | tee plan.txt

      - name: Comment plan on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('terraform/staging/plan.txt', 'utf8');
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '#### Terraform plan (staging)\n<details><summary>Show plan</summary>\n\n<pre>'
                    + plan.slice(-60000) + '</pre>\n</details>',
            });

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: |
          terraform plan -out=tfplan
          terraform apply tfplan

Two things worth understanding in that file.

Authentication. The workflow never sees a long-lived AWS access key. id-token: write lets the job request a short-lived OIDC token from GitHub, and configure-aws-credentials trades it for temporary credentials on a role you create once, with a trust policy scoped to your repo and branch. This is strictly better than storing AWS_ACCESS_KEY_ID as a repo secret. If you’re on GCP or Azure, the equivalents are google-github-actions/auth and azure/login — same idea, same reason. Whatever you do, don’t leave the auth step out: without credentials, terraform init cannot reach the S3 backend and the job dies before it plans anything.

Apply is a fresh plan, not the reviewed one. The apply step re-plans and applies that plan file, which guarantees Terraform applies exactly what it just computed. It does not guarantee that this matches the plan someone approved on the PR — the world may have moved between review and merge (a drifted resource, a new provider version, another merge). A bare terraform apply -auto-approve hides that gap entirely; at least this way the applied diff is printed in the job log. For staging, re-planning is the right trade-off. For prod, carry the reviewed tfplan through as an artifact, or gate the apply behind a human who reads the diff.

Production applies should require manual approval — use GitHub Actions environments with required reviewers, or a separate workflow that must be manually triggered.

Variables and secrets in Terraform
#

Don’t hardcode environment-specific values or secrets in .tf files.

Use variables.tf for inputs:

variable "db_password" {
  description = "Database password"
  type        = string
  sensitive   = true
}

Pass values via:

  • terraform.tfvars files (one per environment, not committed to git if they contain secrets).
  • Environment variables: TF_VAR_db_password=...
  • CI secrets injected as env vars at runtime.

For the actual secrets (passwords, API keys) themselves: Terraform manages their existence but ideally doesn’t hold the values long-term. Use your cloud’s secrets manager for the runtime values and let Terraform provision the secret resource (the slot), not necessarily the value.

Modules: when to use them
#

A module is just a reusable chunk of Terraform config.

Use modules when you genuinely repeat the same pattern — a database with its security group and backup policy, or a service with its load balancer and DNS record.

Don’t create modules prematurely. A common mistake is building a module for one resource you have once. Start with flat files, extract a module when you write the same config a second or third time.

Rule of thumb: if you can read the whole environment in one main.tf in under 10 minutes, you don’t need more abstraction yet.

Handling drift
#

Drift is when the real infrastructure doesn’t match the Terraform state — someone clicked in the console, a resource was changed outside Terraform, or a migration happened manually.

Practical approach:

  • Run terraform plan regularly (in CI or a scheduled job) and alert on unexpected changes.
  • Treat drift as a bug to fix, not a feature.
  • Use terraform import to bring unmanaged resources under Terraform control when needed.

The easiest way to avoid drift: make Terraform the only path for infrastructure changes, and remove console write access for production where possible.

Common anti-patterns to avoid
#

  • State in git. Don’t. Use remote state.
  • One state for everything. Separate staging and prod. A bad plan against prod should be blocked by process, not luck.
  • Applying from personal laptops with personal credentials. Use CI and machine credentials.
  • Over-modularizing before you feel the pain. Premature abstraction in Terraform is just as painful as in code.
  • Storing sensitive output in state without encryption. Always encrypt state at rest.
  • No state locking. Without locking (use_lockfile = true on the S3 backend, or the equivalent on your backend), two concurrent applies can corrupt state.

Quick checklist for a healthy Terraform setup
#

  • Remote state with encryption and versioning enabled.
  • State locking configured.
  • .tfstate and terraform.tfvars (if containing secrets) in .gitignore.
  • Separate state files for staging and prod.
  • terraform plan runs automatically on PR.
  • terraform apply runs in CI, not from personal terminals.
  • Variables used for all environment-specific values.
  • No hardcoded secrets in .tf files.

If you can tick these boxes, you have a solid foundation.

Terraform vs OpenTofu
#

OpenTofu is a community-driven fork of Terraform, created after HashiCorp changed the license. It’s drop-in compatible for most use cases.

For a small team today:

  • Terraform is still the default with the widest ecosystem, provider support, and documentation.
  • OpenTofu is a sensible choice if the licensing change matters to you or your company policy requires OSS.

Either works. Pick one and standardize.

Related reads #

Reply by Email