Skip to content
End To End Tester

Terraform for Test Infrastructure

Provisioning ephemeral test environments as code — per-branch stacks, workspaces, cost controls, and testing the Terraform itself.

1 min read · updated 19 September 2026

Most test suites do not need this. Testcontainers covers databases, brokers and stubs faster, cheaper and with no credentials.

Terraform earns its place in two cases: when the tests need managed cloud services with no container equivalent, and when you need an environment shaped like production — for load testing or for a realistic end-to-end run.

#An ephemeral per-branch environment

hcl
# test-env/main.tf
terraform {
  required_version = ">= 1.9"
  backend "s3" {
    bucket         = "acme-tf-state"
    key            = "test-envs/terraform.tfstate"   # workspace is appended
    region         = "eu-west-2"
    dynamodb_table = "acme-tf-locks"
  }
}

variable "branch" {
  type = string
  validation {
    # The name becomes part of DNS and resource names.
    condition     = can(regex("^[a-z0-9-]{1,32}$", var.branch))
    error_message = "branch must be lowercase alphanumeric with hyphens, 32 chars or fewer."
  }
}

variable "ttl_hours" {
  type    = number
  default = 8
}

locals {
  name = "test-${var.branch}"

  # Every resource carries these. The reaper reads expires_at; finance
  # reads cost_centre; a human reads branch.
  tags = {
    environment = "test"
    branch      = var.branch
    managed_by  = "terraform"
    expires_at  = timeadd(timestamp(), "${var.ttl_hours}h")
    cost_centre = "engineering"
  }
}

resource "aws_db_instance" "test" {
  identifier     = local.name
  engine         = "postgres"
  engine_version = "16.4"                  # the version production runs
  instance_class = "db.t4g.micro"
  allocated_storage = 20

  db_name  = "app"
  username = "app"
  password = random_password.db.result

  skip_final_snapshot = true               # it is disposable, by design
  deletion_protection = false
  backup_retention_period = 0
  apply_immediately   = true

  tags = local.tags
}

resource "aws_sqs_queue" "orders" {
  name                       = "${local.name}-orders"
  visibility_timeout_seconds = 30
  tags                       = local.tags
}

output "database_url" {
  value     = "postgres://app:${random_password.db.result}@${aws_db_instance.test.endpoint}/app"
  sensitive = true
}

output "queue_url" { value = aws_sqs_queue.orders.url }

#Wiring it into CI

yaml
# .github/workflows/e2e-ephemeral.yml
name: E2E against an ephemeral environment

on:
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: e2e-${{ github.head_ref }}
  cancel-in-progress: true

permissions:
  id-token: write        # OIDC to AWS — no long-lived keys in secrets
  contents: read

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    env:
      TF_WORKSPACE: ${{ github.head_ref }}
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/ci-test-env
          aws-region: eu-west-2

      - uses: hashicorp/setup-terraform@v3

      - name: Provision
        id: apply
        working-directory: test-env
        run: |
          terraform init
          terraform workspace select -or-create "$TF_WORKSPACE"
          terraform apply -auto-approve \
            -var="branch=$(echo "$TF_WORKSPACE" | tr '/A-Z' '-a-z' | cut -c1-32)"
          echo "database_url=$(terraform output -raw database_url)" >> "$GITHUB_OUTPUT"

      - name: Migrate and seed
        run: npm ci && npm run migrate && npm run seed:test
        env:
          DATABASE_URL: ${{ steps.apply.outputs.database_url }}

      - name: Run the suite
        run: npx playwright test
        env:
          DATABASE_URL: ${{ steps.apply.outputs.database_url }}

      # always(): a cancelled or failed run must still tear down, or the
      # account fills with orphaned databases.
      - name: Destroy
        if: always()
        working-directory: test-env
        run: |
          terraform destroy -auto-approve -var="branch=$TF_WORKSPACE" || true
          terraform workspace select default
          terraform workspace delete "$TF_WORKSPACE" || true

#The cost controls that actually work

The if: always() destroy step is necessary and not sufficient — a cancelled job, a runner that dies, or a failure inside destroy all leave resources behind. Three layers:

1. TTL tags on everything. Already in the locals block above.

2. A scheduled reaper.

yaml
# .github/workflows/reap.yml
on:
  schedule: [{ cron: '0 * * * *' }]

jobs:
  reap:
    runs-on: ubuntu-latest
    steps:
      - name: Destroy expired test environments
        run: |
          aws resourcegroupstaggingapi get-resources \
            --tag-filters Key=environment,Values=test \
            --query 'ResourceTagMappingList[].[ResourceARN,Tags[?Key==`expires_at`].Value|[0]]' \
            --output text \
          | while read -r arn expires; do
              if [[ "$(date -d "$expires" +%s)" -lt "$(date +%s)" ]]; then
                echo "expired: $arn"
                ./scripts/destroy-by-arn.sh "$arn"
              fi
            done

3. A budget alarm. AWS Budgets, or the equivalent, on the environment=test tag, alerting at a threshold you would not want to discover monthly.

#Modules, so environments are consistent

hcl
module "test_env" {
  source = "git::https://github.com/acme/tf-modules.git//test-env?ref=v2.3.0"

  branch          = var.branch
  instance_class  = "db.t4g.micro"     # the only thing that differs from prod
  engine_version  = "16.4"             # pinned to production's version
}

Pinning the engine version to production's is the point of doing this at all. A test environment on a different Postgres major is a fake with a drift problem, and the drift is in the database behaviour you were trying to verify.

#Testing the Terraform itself

hcl
# test-env/tests/naming.tftest.hcl — built into Terraform since 1.6
variables {
  branch = "feature-checkout"
}

run "resources_are_namespaced_by_branch" {
  command = plan

  assert {
    condition     = aws_db_instance.test.identifier == "test-feature-checkout"
    error_message = "the database identifier must include the branch"
  }
}

run "rejects_an_unsafe_branch_name" {
  command = plan
  variables { branch = "Feature/Checkout" }
  expect_failures = [var.branch]
}
bash
terraform test

That runs against a plan, in seconds, with no cloud resources created. For the smaller number of assertions that must be made against real infrastructure, Terratest applies a stack from Go and asserts against it — powerful, slow, and worth reserving for modules that other teams depend on.

#When not to reach for this

Real cloud infrastructure per pull request is slow (minutes to provision an RDS instance), costly, and introduces a whole new category of CI failure that has nothing to do with your code. Use it where the realism is the point, and containers everywhere else.

Common questions

Do I need Terraform for test environments?
Only when the tests need real cloud services. For a database, a broker or a stubbed HTTP dependency, Testcontainers is faster, cheaper and needs no credentials. Terraform earns its place for managed services with no container equivalent and for load testing against production-shaped infrastructure.
How do I avoid leaking cloud resources from CI?
Destroy in an always-run step, tag everything with the branch and a TTL, and run a scheduled reaper that deletes anything past its TTL. Relying on the destroy step alone guarantees orphans, because cancelled jobs never reach it.
Can I test Terraform code itself?
Yes. terraform test (built in since 1.6) runs assertions against plans and applies. Terratest goes further, applying real infrastructure and asserting against it from Go. Use the first for module logic and the second sparingly, for the cases that must be proven against the real provider.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?