Managing Dev, Staging and Production with Infrastructure as Code
Patterns for running multiple environments with Terraform: reusable modules, isolated state, per-environment configuration, promotion through pipelines and drift detection.
By the PageChck engineering team · · 10 min read
“It works in staging” is only reassuring if staging actually looks like production. Too often it doesn’t: a security group someone edited by hand, a database parameter changed during an incident, a feature enabled in one console and forgotten in another. Over time each environment becomes a unique snowflake, and every release is a little less predictable.
Infrastructure as Code (IaC) fixes this by describing every environment in version-controlled code. But simply “using Terraform” isn’t enough — the structure you choose determines whether environments stay consistent or slowly drift apart. Here are the patterns we use with clients on AWS, GCP and Azure.
The goal: same shape, different size
Development, staging and production should be built from the same code and differ only in explicit, reviewed parameters: instance sizes, replica counts, domain names, retention periods. If an environment needs something structurally different, that difference should be visible in code review, not discovered during an outage.
1. Reusable modules as building blocks
Package common infrastructure — a network, a Kubernetes cluster, a database, a service with its load balancer and alarms — into modules with clear inputs and outputs. Environments then compose those modules instead of copy-pasting resources.
- Keep modules small and focused; avoid one giant “everything” module.
- Give inputs sensible, secure defaults so the simplest usage is also the safe one.
- Version your modules (Git tags or a private registry) so production can pin a known-good version while dev tries the next one.
2. A directory per environment, state per environment
The most robust layout for most teams is a thin root configuration per environment that calls shared modules, each with its own isolated state:
infra/
├── modules/
│ ├── network/
│ ├── k8s-cluster/
│ └── postgres/
└── envs/
├── dev/
│ ├── main.tf
│ ├── backend.tf
│ └── terraform.tfvars
├── staging/
│ └── ...
└── prod/
└── ...# envs/prod/main.tf
module "network" {
source = "git::https://github.com/acme/infra-modules.git//network?ref=v1.8.0"
name = "prod"
cidr_block = "10.30.0.0/16"
az_count = 3
}
module "db" {
source = "git::https://github.com/acme/infra-modules.git//postgres?ref=v1.8.0"
name = "orders-prod"
instance_class = var.db_instance_class
multi_az = true
backup_retention = 30
subnet_ids = module.network.private_subnet_ids
}Why separate state matters:
- A mistake while planning dev can never touch production resources.
- Each environment can be locked, planned and applied independently.
- Access can be restricted — far fewer people need write access to production state.
Store state remotely with locking — S3 with DynamoDB or native S3 locking, a GCS bucket, or Azure Blob Storage — with encryption and versioning enabled. Terraform workspaces can work for truly identical, short-lived environments, but separate directories make differences explicit and are easier to reason about.
Plain directories, Terragrunt or Atmos?
Plain directories are the easiest to understand, but the backend.tf, provider blocks and module calls start to repeat as environments and regions multiply. Two tools address that duplication:
- Terragrunt wraps Terraform or OpenTofu and lets you define backends, providers and common inputs once, inheriting them per environment. It also orchestrates applies across dependent stacks.
- Atmos describes stacks in YAML with layered configuration (organization, account, region, environment), which suits larger platforms with many accounts and teams.
Our rule of thumb: start with plain directories for up to three or four environments in one region. Reach for Terragrunt when the copy-paste starts to hurt, and Atmos when you manage dozens of accounts.
Terraform or OpenTofu?
After HashiCorp moved Terraform to the Business Source License in 2023, the community forked it as OpenTofu, now a Linux Foundation project. Everything in this article applies to both: the language, modules and state format are largely compatible, and migrating an existing project is usually a matter of switching binaries and testing. Choose based on your licensing position, the features you need (OpenTofu added client-side state encryption, for example) and what your tooling supports.
3. Isolate environments at the account level
Code isolation is only half of the story. Put each environment in its own AWS account, GCP project or Azure subscription. This gives you hard security boundaries, clean cost reporting and protection against a misconfigured IAM policy in dev granting access to production data.
4. Configuration and secrets
- Keep per-environment values in small, readable
.tfvarsfiles — sizes, counts, feature toggles. - Never put secrets in code or tfvars. Reference them from AWS Secrets Manager, GCP Secret Manager or Azure Key Vault, and let workloads read them at runtime.
- Prefer convention over configuration: derive names, tags and CIDRs from the environment name where possible so there is less to get wrong.
5. Promote changes through a pipeline
Infrastructure changes deserve the same delivery discipline as application code. A typical flow:
- A pull request runs
fmt,validate, linting, security and policy checks (tfsec, Checkov, OPA) and posts aterraform planfor every affected environment. - Reviewers approve the plan, not just the code.
- On merge, the pipeline applies to dev, then staging.
- Production applies after a manual approval of the exact plan that will be executed.
Run applies from CI with short-lived credentials (OIDC federation) rather than from laptops. That creates an audit trail and removes the “works on my machine” problem from infrastructure.
6. Detect and eliminate drift
Even with good intentions, someone will eventually change something in the console during an incident. Schedule a nightly terraform plan for each environment and alert when it is not empty. Then either codify the change or revert it. Pair this with read-only console access for most people in production so that the code stays the source of truth.
7. Make environments cheap to create
When environments are fully described in code, creating a new one becomes a pull request instead of a project. That unlocks ephemeral environments for load tests, customer demos or large feature branches — spun up on demand and destroyed afterwards, so they cost nothing while idle.
Common pitfalls
- Copy-paste environments that diverge after the first hotfix.
- One shared state file for everything, making every plan slow and every apply risky.
- Unpinned module and provider versions, so the same code produces different results on different days.
- Hand-made exceptions in production that never make it back into code.
Bringing it together
Well-structured Infrastructure as Code gives you environments that are consistent, reviewable and reproducible. Staging becomes a trustworthy rehearsal for production, rollbacks of infrastructure changes become possible, and spinning up a new region or customer environment stops being a months-long effort. If your current setup is a mix of console clicks and scripts, start by importing one environment into code, then use it as the template for the rest — or let our Infrastructure as Code team do it with you.