Terraform for Test Environment Provisioning: Infrastructure as Code for QA
Most teams treat test environment provisioning as an operations problem: file a ticket, wait for an engineer to spin something up, hope it matches what you actually need. Infrastructure as Code flips this. When your test environments are defined in Terraform, any team member can provision a complete, correctly configured environment in minutes — and tear it down when done.
This isn't about adding Terraform complexity for its own sake. It's about making test environments as reliable and reproducible as the tests themselves.
Why IaC for Test Environments
The traditional alternative is some combination of: a long-lived shared staging server, manual provisioning steps in a wiki, and a lot of institutional knowledge about which config values matter. This breaks down in predictable ways.
Long-lived environments drift (as covered in the parity post). Manual steps get skipped or done incorrectly. Institutional knowledge leaves when engineers leave. Shared environments create contention — the team running load tests interferes with the team doing exploratory QA.
Terraform solves these problems by making environment provisioning:
- Repeatable — the same module produces the same environment every time
- Version-controlled — environment definitions live in git, not in someone's head
- Automated — CI can provision and destroy environments without human intervention
- Self-documenting — the Terraform files are accurate documentation of what the environment contains
Workspace-Based Environment Isolation
Terraform workspaces let you use the same configuration to manage multiple independent environments. Each workspace has its own state file, so resources don't collide.
# Create a workspace per test run or per branch
terraform workspace new feature-payment-redesign
# Your configuration references the workspace name
locals {
env_name = terraform.workspace
is_ephemeral = !contains(["staging", "production"], local.env_name)
}
resource "aws_db_instance" "app" {
identifier = "testdb-${local.env_name}"
# Ephemeral envs use smaller, cheaper instances
instance_class = local.is_ephemeral ? "db.t3.micro" : "db.t3.medium"
# Skip final snapshot for ephemeral environments
skip_final_snapshot = local.is_ephemeral
tags = {
Environment = local.env_name
Ephemeral = tostring(local.is_ephemeral)
CreatedBy = "ci"
}
}The workspace name flows through your resource naming, so every resource in a feature environment is clearly labeled and isolated from every other environment.
Building a Reusable Test Environment Module
Extract your test environment into a module so it can be invoked identically by CI, by developers locally, and by any other consumer:
modules/
test-environment/
main.tf
variables.tf
outputs.tf
README.md# modules/test-environment/variables.tf
variable "environment_name" {
description = "Unique name for this test environment"
type = string
}
variable "app_image_tag" {
description = "Docker image tag to deploy"
type = string
}
variable "instance_type" {
description = "Compute instance type"
type = string
default = "t3.small"
}
variable "database_size" {
type = string
default = "db.t3.micro"
}# modules/test-environment/main.tf
module "vpc" {
source = "../vpc"
name = var.environment_name
}
resource "aws_ecs_service" "app" {
name = "app-${var.environment_name}"
cluster = aws_ecs_cluster.test.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 1
network_configuration {
subnets = module.vpc.private_subnet_ids
security_groups = [aws_security_group.app.id]
assign_public_ip = false
}
}
resource "aws_alb" "app" {
name = "alb-${var.environment_name}"
subnets = module.vpc.public_subnet_ids
}# modules/test-environment/outputs.tf
output "app_url" {
description = "URL to reach the application"
value = "https://${aws_alb.app.dns_name}"
}
output "environment_name" {
value = var.environment_name
}With this module, provisioning a test environment is a handful of lines:
# environments/pr-1234/main.tf
module "test_env" {
source = "../../modules/test-environment"
environment_name = "pr-1234"
app_image_tag = "sha-abc123def"
instance_type = "t3.small"
}
output "app_url" {
value = module.test_env.app_url
}State Management for Ephemeral Environments
State management is the part that breaks most ephemeral Terraform setups. You need remote state (so CI can access it), but you also need cleanup when the environment is destroyed.
Use a backend configuration that namespaces state by environment:
# backend.tf
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "test-environments/${terraform.workspace}/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}Add a cleanup step to your CI that removes the state file after terraform destroy:
# After terraform destroy succeeds
aws s3 <span class="hljs-built_in">rm <span class="hljs-string">"s3://my-company-terraform-state/test-environments/${WORKSPACE_NAME}/terraform.tfstate"
aws dynamodb delete-item \
--table-name terraform-state-lock \
--key <span class="hljs-string">"{\"LockID\": {\"S\": \"my-company-terraform-state/test-environments/${WORKSPACE_NAME}/terraform.tfstate\"}}"Left-over state files aren't harmful, but they accumulate. A periodic job that lists state files older than 7 days and deletes them prevents the bucket from becoming a graveyard.
Cost Controls
Ephemeral environments are economical by nature — they exist only while needed — but a crashed CI job can leave resources running. Add lifecycle controls:
resource "aws_instance" "app" {
# ...
lifecycle {
# Prevent accidental recreation (would cause downtime mid-test)
prevent_destroy = false
}
# Tag with creation time for cleanup automation
tags = {
CreatedAt = timestamp()
TTL = "4h"
Environment = var.environment_name
}
}Pair this with a Lambda or cron job that terminates instances where the CreatedAt tag plus TTL has elapsed. Even if CI cleanup fails, resources self-terminate.
Feeding Environment URLs Into Your Tests
The final piece is getting the provisioned URL into your test runner. Terraform outputs make this straightforward:
# In CI, after terraform apply
APP_URL=$(terraform output -raw app_url)
<span class="hljs-comment"># Pass to HelpMeTest or your test runner
helpmetest run --base-url <span class="hljs-string">"$APP_URL" --suite smoke-testsYour tests don't need to know or care about the infrastructure that produced the URL. They test behavior at an HTTP endpoint. The infrastructure is just a detail that Terraform manages.
This separation — infrastructure provisioned by Terraform, behavior tested by your test suite — lets both evolve independently. You can change the underlying infrastructure without touching your tests, and you can extend your tests without touching your infrastructure definitions.