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
# 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
# .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.
# .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
done3. 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
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
# 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]
}terraform testThat 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
- A database, queue or cache for integration tests — use Testcontainers.
- AWS service emulation for unit tests — use LocalStack in a container.
- A stubbed third-party API — use WireMock.
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 ↗- HCL
terraform
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Testing in GitHub ActionsA complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.
- TestcontainersRunning real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.
- Load, Stress and Soak TestingFinding where a system breaks rather than how fast it is — load profiles, the difference between the four kinds of test, and reading the results honestly.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- Test Data ManagementWhere a test's data comes from decides whether the suite can run in parallel, twice in a row, or at all — builders, factories, fixtures and per-test isolation.
- Testing in JenkinsDeclarative pipelines, parallel stages, agent control and JUnit reporting — how to run a modern test suite on the CI server you probably inherited.