Back to Blog
DevOps11 min readApril 20, 2025

Terraform on AWS: Infrastructure as Code From Zero to Production

A practical guide to managing AWS infrastructure with Terraform. Covers state management, modules, workspaces, secrets, and CI/CD integration for safe infrastructure changes.

TerraformAWSIaCInfrastructureDevOps
A

Azam

DevOps & AI Consultant

Why Infrastructure as Code Is Non-Negotiable

Manually clicking through AWS consoles to provision infrastructure works until it doesn't — until you need to recreate an environment after an incident, audit what changed and when, or onboard a new engineer who needs to spin up a dev environment. Infrastructure as Code makes your cloud setup reproducible, reviewable, and version-controlled. Terraform is the most widely adopted IaC tool and works across all major cloud providers.

Project Structure for Real-World Terraform

Organise Terraform code into reusable modules and environment-specific root configurations. Avoid putting all resources in a single main.tf — it becomes unmanageable past a few dozen resources.

terraform/
  modules/
    vpc/
      main.tf
      variables.tf
      outputs.tf
    ecs-service/
      main.tf
      variables.tf
      outputs.tf
    rds/
      main.tf
      variables.tf
      outputs.tf
  environments/
    staging/
      main.tf       # composes modules
      terraform.tfvars
      backend.tf
    production/
      main.tf
      terraform.tfvars
      backend.tf

Each module is a self-contained unit with clearly defined inputs and outputs. The environment configurations consume modules with environment-specific variable values.

Remote State with S3 and DynamoDB Locking

Never store Terraform state locally. Use S3 for state storage and DynamoDB for state locking — this prevents two engineers from running terraform apply simultaneously and corrupting state.

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-company-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

# bootstrap the backend (one-time setup)
resource "aws_s3_bucket" "tf_state" {
  bucket = "my-company-terraform-state"
}

resource "aws_s3_bucket_versioning" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_dynamodb_table" "tf_lock" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  attribute {
    name = "LockID"
    type = "S"
  }
}

Managing Secrets: Never in State

Terraform state is stored in plaintext in S3. Never put secrets — database passwords, API keys, TLS private keys — as Terraform variables that end up in state. Use AWS Secrets Manager or SSM Parameter Store and reference secrets by ARN.

# Store the secret outside Terraform (manually or via AWS CLI)
# Reference it in Terraform without reading the value into state
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "prod/db/password"
}

resource "aws_ecs_task_definition" "app" {
  # ...
  container_definitions = jsonencode([{
    environment = [
      {
        name  = "DB_PASSWORD"
        value = data.aws_secretsmanager_secret_version.db_password.secret_string
      }
    ]
  }])
}

CI/CD Integration: Safe Automated Applies

Run terraform plan on every pull request and post the plan as a PR comment. Only run terraform apply after a merge to main, with a required manual approval step for production.

# .github/workflows/terraform.yml
on:
  pull_request:
    paths: ['terraform/**']

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Plan
        run: |
          terraform init
          terraform plan -out=tfplan
        working-directory: terraform/environments/production
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Post plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              body: `## Terraform Plan
```${planOutput}````
            })

Common Pitfalls

  • Destroying and recreating resources on rename: Use moved blocks in Terraform 1.1+ to tell Terraform that a resource was renamed, not replaced.
  • Drift between state and reality: Run terraform refresh or use terraform import to bring manually-created resources under Terraform management.
  • Everything in one workspace: Use separate state files per environment — never share state between staging and production.
  • Hardcoded AMI IDs: Use data sources to look up the latest AMI dynamically so instances don't fall behind on security patches.

Terraform invested in upfront is the best insurance policy for your infrastructure. The teams that skip it pay the debt in incident recovery time, audit failures, and environment inconsistency.

Want to Build This for Your Team?

I help teams implement the patterns and architectures described in these articles. Let's talk about your project.

Book a Free Call