Infrastructure as Code From Scratch: Environment Strategy for a New Project
Repository layout, state isolation, environment promotion and drift detection, laid out in the order that avoids the mistakes teams make when they start with a flat Terraform folder and a single state file.
By Subeesh Sivanandan, Founder & CEO, Stonetusker Systems · Updated October 2026 · Est. reading time: 11 minutes
Executive Brief
- Infrastructure as code from scratch means choosing a repository layout, a state isolation strategy and a promotion model before writing the first resource block. Getting these wrong early creates problems that compound for years.
- Separate state files per environment (dev, staging, production) from day one. A single shared state file is the most common IaC anti-pattern and the one with the highest remediation cost.
- The repository layout decision (monorepo vs. polyrepo, flat vs. nested modules) depends on team size and blast radius tolerance, not on which layout looks cleaner in a blog post.
- Environment promotion (dev to staging to production) should flow through the same code path. If staging uses a different module version or a different variable file structure than production, you're not testing what you think you're testing.
- Drift detection is the control that keeps your IaC honest. Without scheduled plan-only runs that compare actual infrastructure to declared state, manual changes accumulate silently until the next
applybreaks something unexpected. - The four questions that shape every architecture apply directly here: what has to be true before a change ships, how you prove it six months later, what undo costs, and what a test environment physically has to be.
How IaC Goes Wrong in the First Month
The first Terraform or OpenTofu file in a project usually gets written in a hurry. Someone needs a VPC, a database and a load balancer for a demo. They create a single main.tf, hard-code the region, put the state in a local file, and run apply. It works. That's the problem.
Three months later the team has four environments, the state file is in an S3 bucket that everyone shares, and nobody is sure whether the staging VPC was created by Terraform or by someone clicking through the console at 2 a.m. during an incident. The plan output shows changes nobody expected. Someone runs apply anyway. Production drops a security group rule.
This pattern is not a failure of tooling. It's a failure of sequencing. The decisions that prevent it, how to lay out repositories, how to isolate state, how to promote changes between environments, need to happen before the first resource block, not after the first outage.
The rest of this article lays out those decisions in order, starting with the ones that are hardest to change later.
Repository and State Layout
Two decisions sit underneath every IaC project. First, how the code is organised in version control. Second, how state is isolated between environments and components.
Repository layout
There are three common patterns:
| Pattern | Structure | Works well when | Breaks when |
|---|---|---|---|
| Single repo, flat | All resources in one directory, one state file per environment via workspaces or backend config | Small team, one product, simple infrastructure | Multiple teams or products share the repo and step on each other's plans |
| Single repo, nested | Top-level directories per component (network, compute, data), shared modules in a /modules directory | One team owns all infrastructure but wants independent apply cycles per component | Module versioning becomes painful because consumers and producers are in the same repo |
| Polyrepo | Separate repositories per domain or service, shared modules published as versioned packages | Multiple teams, each owning their infrastructure independently | Small teams with no internal package registry; the overhead exceeds the benefit |
For a team starting infrastructure as code from scratch with a single product, the nested monorepo is usually the right starting point. It gives you independent state per component without the overhead of multiple repositories and a package registry for shared modules.
A typical layout:
infra/
environments/
dev/
network/
main.tf
backend.tf # points to dev-network state
compute/
main.tf
backend.tf # points to dev-compute state
staging/
network/
compute/
production/
network/
compute/
modules/
vpc/
ecs-service/
rds-cluster/Each backend.tf points to a distinct state file. The modules/ directory holds reusable components consumed by environment-specific configurations. This layout is self-documenting: a new team member can find what controls what without reading a README.
State isolation
One state file per environment per component. This is the rule. Not one state file per environment (too coarse: a change to a security group triggers a plan that touches the database), and not one state file for everything (unrecoverable: a corrupt state takes down all environments).
Where state files live matters too. Remote backends (S3 + DynamoDB for locking, GCS + Cloud Storage, Azure Blob + lease locking) are the baseline. Local state files have no locking, no versioning and no access control. They belong in prototypes, not in anything that serves traffic.
terraform destroy in the wrong directory right now, which environments would it affect? If the answer is "more than one," your state isolation has a gap.Environment Strategy and Promotion
Having separate state files per environment is necessary but not sufficient. The promotion model, how a change moves from dev through staging to production, determines whether your environments actually test what you think they test.
The identical-code principle
Every environment should use the same Terraform/OpenTofu modules at the same version. The only things that differ are the input variables: instance sizes, replica counts, domain names, account IDs. If your staging environment uses a different module source or a different module version than production, then deploying to staging proves nothing about what will happen in production.
In practice, this means your environment directories consume modules by reference (a local path in a monorepo, or a versioned registry source in a polyrepo), and the per-environment differences live entirely in variable files (.tfvars) or in the backend configuration.
Promotion flow
- Develop and plan in dev A developer changes a module or an environment variable, opens a pull request, and CI runs
terraform planagainst the dev state. The plan output is posted as a PR comment. - Apply to dev On merge to the main branch, CI applies the change to dev automatically (or on approval, depending on the team's risk tolerance).
- Promote to staging The same commit is applied to staging, either automatically on a schedule or triggered manually. The plan is reviewed before apply. If staging uses different variable values (smaller instances, fewer replicas), those are already encoded in the staging
.tfvarsfile. - Promote to production After validation in staging, the same commit is applied to production. This step always requires explicit approval, and ideally runs through a separate CI pipeline with stricter access controls.
The key constraint: no cherry-picking. If a hotfix needs to go directly to production, it still follows the same code path, just faster. A separate "production-only" branch that diverges from the main branch is the beginning of drift between environments.
Variable management
Keep variable files per environment (dev.tfvars, staging.tfvars, production.tfvars) and pass them explicitly in CI. Don't rely on terraform.tfvars auto-loading, which obscures which values are being used in which context.
Sensitive values (database passwords, API keys) don't belong in .tfvars files at all. Pull them from a secrets manager at apply time. The data block pattern for reading secrets from Vault or AWS Secrets Manager is well-documented and avoids storing credentials in version control.
GitOps Versus CI-driven Apply
Two models exist for getting IaC changes from a merged pull request to actual infrastructure.
| Dimension | CI-driven apply | GitOps (pull-based) |
|---|---|---|
| How apply triggers | CI job runs terraform apply on merge or manual approval | A controller (Argo CD, Flux) watches the repo and reconciles state continuously |
| Best suited for | Cloud infrastructure (VPCs, databases, IAM, DNS) | Kubernetes-native resources (deployments, services, config maps, Helm releases) |
| Drift handling | Requires a separate scheduled plan; doesn't self-heal by default | Continuous reconciliation; reverts manual changes automatically |
| Visibility | Plan output in PR comments and CI logs | Dashboard showing sync status, diff between desired and actual |
| Rollback | Revert the commit and re-apply; state history in the backend | Revert the commit; the controller reconciles to the previous state |
For infrastructure as code from scratch, the answer is usually both, divided by layer. Cloud infrastructure (the stuff Terraform/OpenTofu manages) runs through CI-driven apply. Kubernetes workloads and configuration run through a GitOps controller. Trying to force all infrastructure through a GitOps controller is possible but adds complexity without proportional benefit: Argo CD and Flux are designed for Kubernetes resources, not for managing VPCs and RDS instances.
The boundary is simple: if the resource has a Kubernetes API, it goes through GitOps. If it talks to a cloud provider API, it goes through CI-driven apply.
terraform apply from a laptop, your apply path isn't automated yet.Drift Detection
Drift is the gap between what your IaC code declares and what actually exists in the cloud provider. It happens for three reasons: someone made a manual change through the console, a CI apply partially failed and left resources in an intermediate state, or an upstream provider change (a default that shifted, a deprecation) altered the behaviour of an existing resource.
Without drift detection, you discover these gaps at the worst time: during the next apply, when the plan shows unexpected changes mixed in with the intended ones, and someone has to decide on the spot whether those extra changes are safe.
Scheduled plan-only runs
The simplest form of drift detection is a scheduled CI job that runs terraform plan (without -auto-approve) against each environment and alerts when the plan is non-empty. If no changes were made in the code and the plan shows drift, something changed outside the pipeline.
Run these at least daily for production, and on whatever cadence makes sense for lower environments. The output goes to a Slack channel, a ticketing system or an email alias, and the team treats non-empty plans as incidents to triage.
What to do when drift is detected
Three options, depending on what drifted and why:
Import: If someone created a resource manually and it should exist, import it into state and add the corresponding code. The resource now belongs to IaC.
Reconcile: If someone changed a resource's configuration manually, overwrite the manual change by running apply. The code is the source of truth, not the console.
Update code: If the manual change was correct (an emergency fix during an incident, for example), update the code to match reality, then run plan to confirm the drift is gone.
The wrong answer is to ignore it. Accumulated drift makes every future plan harder to read, which makes teams less confident about applying, which makes them more likely to make changes manually, which creates more drift. It's a cycle that only breaks with a policy: no non-empty plans that aren't explained.
Compliance and Audit Connection
IaC provides a version-controlled, auditable record of every infrastructure change: who proposed it (the PR author), who approved it (the reviewer), when it was applied (the CI timestamp), and what changed (the plan diff). This audit trail maps directly to change-management requirements across multiple frameworks.
For teams operating under SOC 2 Type II, the plan-review-approve-apply workflow produces the evidence auditors ask for: separation of duties between the person who wrote the change and the person who approved it, and a log of what was applied and when.
Policy-as-code tools (OPA with Conftest, Sentinel, or Kyverno for Kubernetes resources) can enforce guardrails at plan time: no public S3 buckets, no security groups open to 0.0.0.0/0, no unencrypted databases. These policies run as part of the PR check, before any human reviews the plan, and they produce a pass/fail record that auditors can verify.
See Policy as Code for Platform Engineering for a deeper treatment of how policy gates fit into a platform team's workflow.
Should You Build This or Bring in Help?
Setting up infrastructure as code from scratch is a one-time project with a defined scope. Repository layout, state isolation, promotion pipeline, drift detection, policy gates: each piece is well-understood and the tooling is mature.
The question, as always, is bandwidth. If the engineers who should be setting this up are the same engineers building the product, one of those things gets done poorly. Usually it's the infrastructure, because the product has a deadline and the IaC "can always be fixed later." By the time later arrives, the state file situation is a mess and the remediation cost has tripled.
Factors that push toward bringing in outside help:
- The team has never set up a multi-environment IaC project before. The mistakes are well-known, but learning them by making them costs months.
- The project has a compliance requirement (SOC 2, HIPAA, a regulated industry) that demands an auditable change-management trail from day one, not bolted on later.
- The team is small (under five engineers) and can't afford to have one person dedicated to infrastructure full-time during the setup phase.
Take the Stonetusker readiness check to see where your infrastructure stands relative to production-grade IaC practices. It's free and doesn't require an email address.
Stonetusker's Platform Foundation practice sets up exactly this kind of IaC environment, from repository layout and state isolation through promotion pipelines and drift detection, within a fixed 90-day engagement. If you're about to start a new project and want the infrastructure foundations right from the beginning, book a 30-minute call to talk through what that looks like for your team.
- Infrastructure as Code
- Terraform
- OpenTofu
- State Management
- GitOps
- Drift Detection
- Environment Promotion
- Platform Engineering
Frequently Asked Questions
What is infrastructure as code?
Infrastructure as code (IaC) is the practice of defining cloud infrastructure (networks, servers, databases, load balancers, IAM policies) in declarative configuration files rather than provisioning them manually through a web console or CLI. The configuration files are stored in version control, reviewed through pull requests and applied through automated pipelines. This gives teams a repeatable, auditable, version-controlled way to manage infrastructure, with the same collaboration patterns they already use for application code.
How should I organise my Terraform state files?
Use one state file per environment per component. For example, your dev network has its own state file, your dev compute layer has its own, and your production network has its own. Store all state files in a remote backend (S3 with DynamoDB locking, GCS, or Azure Blob Storage) with versioning enabled. Never use a single state file for multiple environments: if the state becomes corrupted or a destructive operation runs against the wrong environment, the blast radius is everything the state file controls.
What is the difference between GitOps and CI-driven infrastructure apply?
CI-driven apply means a CI pipeline runs terraform plan and terraform apply when code is merged. It's a push model: the pipeline pushes changes to the infrastructure. GitOps is a pull model: a controller running inside the cluster (Argo CD or Flux) watches a Git repository and continuously reconciles the cluster's actual state with the desired state declared in the repo. CI-driven apply works best for cloud infrastructure (VPCs, databases, IAM). GitOps works best for Kubernetes-native resources (deployments, services, config maps). Most teams use both, divided by the type of resource being managed.
How do I detect infrastructure drift?
Run terraform plan (without -auto-approve) on a schedule against each environment, at least daily for production. If the plan output is non-empty and no code changes were made, something was changed outside the pipeline: a manual console edit, a partially failed apply, or an upstream provider change. Route the drift report to a team channel and triage it like any other incident. The three responses are: import the resource if it should exist, reconcile by applying to overwrite the manual change, or update the code if the manual change was correct.
Should I use Terraform workspaces to manage multiple environments?
Terraform workspaces can separate state within a single backend configuration, but they share the same backend configuration and code directory. For small projects with identical environments that differ only in variable values, workspaces are a pragmatic choice. For anything larger, explicit directory-per-environment structures are clearer: each environment has its own backend.tf pointing to its own state, its own .tfvars file, and its own CI pipeline configuration. The directory approach is more verbose but more transparent, and it avoids the confusion that arises when someone runs terraform workspace select production from a directory they expected to only affect dev.
Starting a new project and want infrastructure as code set up properly from the first commit? Stonetusker's Platform Foundation practice builds production-grade IaC environments within a fixed 90-day engagement.
Talk to StonetuskerSources and Related Reading
Related reading on Stonetusker
External sources:
- Terraform state management documentation
- OpenTofu documentation
- Argo CD documentation
- Flux CD documentation
About the Author
Subeesh Sivanandan is the Founder and CEO of Stonetusker Systems. With 26+ years across platform engineering, DevOps, CI/CD, release engineering and embedded Linux at organisations including Nokia, Stryker, VeriSign and IP Infusion, he helps engineering teams build delivery infrastructure that holds up under production pressure and regulatory scrutiny. Connect on LinkedIn.
Last reviewed: October 2026 · Tool versions checked on: October 2026



