The first time you describe infrastructure as code and run apply on your own machine, everything works, and that's inspiring. But between "works for me alone" and "works for a team in production" lie several important things that tutorials often stay silent about. State, secrets, separating environments, and automated delivery are what turn a textbook example into a real project.

This article is an overview map of these topics for those who have already tried infrastructure as code (Infrastructure as Code, IaC, describing servers, networks, and services as configuration files) and want to understand what's missing before going "for real". If the basic concepts are still hazy, start with the IaC Fundamentals section, then come back.

Where to keep state and why lock it

State is a file in which Terraform remembers what it has already created in the cloud: which resources, with which IDs and settings. Without it, Terraform doesn't know what to change and what to leave alone. Think of it as an accounting ledger: in reality there are servers in AWS, in the ledger there are records about them, and the two must reconcile.

While you're learning, state sits as a terraform.tfstate file next to the code. For one person that's fine. But as soon as a second developer shows up, trouble begins: each has their own copy of the ledger, and they diverge.

The solution is remote state: keep the file not locally but in a shared place that everyone looks at. On AWS that's usually an Amazon S3 bucket. Then there's one ledger for everyone.

terraform {
  backend "s3" {
    bucket = "myteam-tfstate"
    key    = "prod/network/terraform.tfstate"
    region = "eu-central-1"
  }
}

But a shared file gives rise to a second problem: what if two people run apply at the same time? They'll overwrite each other's changes and can corrupt the state. To prevent this, you need locking: while one apply is running, the others wait, like a queue for a single booth.

The classic way is a table in DynamoDB: Terraform creates a lock record there for the duration of the operation.

terraform {
  backend "s3" {
    bucket         = "myteam-tfstate"
    key            = "prod/network/terraform.tfstate"
    region         = "eu-central-1"
    dynamodb_table = "tfstate-lock"
  }
}

Starting with Terraform 1.10 there's a simpler option: native locking by S3 itself, with no separate table needed; the DynamoDB variant is now considered deprecated:

terraform {
  backend "s3" {
    bucket       = "myteam-tfstate"
    key          = "prod/network/terraform.tfstate"
    region       = "eu-central-1"
    use_lockfile = true
  }
}

A useful habit: enable versioning on the S3 bucket, so corrupted state can be rolled back to a previous version.

An important fork in tooling. With Terraform, state is your responsibility. But CloudFormation and CDK (a layer on top of CloudFormation, where infrastructure is described in an ordinary programming language) manage state themselves inside AWS: there's no separate file, no bucket needed for state, and locking is on the AWS side too. More on the tools in the articles about Terraform, CloudFormation, and CDK.

Secrets: what you must not do

A secret is any value whose leak is dangerous: a database password, an API key, a token. With secrets in IaC there are two iron rules.

First: don't commit secrets to git. A file with a password in the repository is a password visible to everyone with access to the code, and one that stays in history forever, even if you later delete it.

Second, less obvious: a secret must not sit in plain text in the state. If you write a password directly into the configuration, Terraform will save it in the state file in the clear, which means anyone who reads the state learns the password. That's the same leak, just in a different place.

The right approach is to keep secrets in a dedicated service and pull them from there:

  • AWS Secrets Manager is a store with encryption and automatic rotation (changing) of passwords, convenient for database credentials.
  • SSM Parameter Store holds application parameters; the SecureString type encrypts the value with a KMS key.

In Terraform a secret is read through a data source, a reference to an already existing value rather than to something Terraform itself creates:

data "aws_secretsmanager_secret_version" "db" {
  secret_id = "prod/db/password"
}

Good discipline: don't multiply sensitive values in state without need, and in any case lock down the state file itself with strict access permissions. Access management is the topic of the article on IAM.

Multiple environments (dev, prod)

Almost every project has at least two environments: dev, where you experiment and it's not scary to break things, and prod, where real users live and breaking things is off-limits. The main goal is that an accidental change in dev physically cannot touch prod.

In Terraform there are two paths. Workspaces separate state within a single bucket and a single configuration, which is convenient for small things but dangerous for prod: it's easy to mix up the active workspace and apply changes in the wrong place. For reliable production isolation, it's better to have separate directories and separate state for each environment: dev has its own folder and its own state key, prod has its own.

environments/
  dev/   -> key = "dev/terraform.tfstate"
  prod/  -> key = "prod/terraform.tfstate"

Different state files mean different "ledgers", and one environment doesn't see the other's resources. In the CloudFormation/CDK world the same principle is achieved with separate stacks (a stack is a named set of resources): myapp-dev and myapp-prod exist independently.

IaC in the delivery pipeline

Running apply by hand from your laptop is fine while you're alone and the project is a learning exercise. In a team it turns into a delivery pipeline (CI/CD, an automated process of building and rolling out changes), and infrastructure rides the same rails as the code.

The basic workflow is simple and step by step:

  1. You open a pull request (a request to merge a branch) with an infrastructure change.
  2. The pipeline automatically runs terraform plan, a dry run that shows what will change without touching anything. The plan lands in the pull request discussion, and colleagues review it.
  3. After approval and merge, the pipeline runs terraform apply and the changes are applied. In CloudFormation/CDK the same role is played by cloudformation deploy and cdk deploy.

This way no production change happens "quietly": first a visible plan under review, then the apply.

One question remains: how does the pipeline sign in to AWS? The temptation is to put a permanent access key in the CI settings. That's a bad idea: such a key is long-lived, and if it leaks, it can be used indefinitely. The right way is OIDC federation (OpenID Connect): CI obtains on the fly a short-lived token valid for exactly one job, and AWS grants temporary permissions based on trust rather than a stored password.

permissions:
  id-token: write
  contents: read
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::111122223333:role/ci-terraform
    aws-region: eu-central-1

The principles of the pipeline itself are in the Pipeline Principles and Release Strategies sections.

How to catch drift

Drift is the discrepancy between what's described in code and what's actually in the cloud. It appears when someone edits a resource by hand through the console: the code says one thing, reality another, and the next apply brings surprises.

The easiest way to catch drift is a regular check. In Terraform that's a periodic terraform plan: if the plan is non-empty even though you changed nothing, then reality has drifted from the code.

terraform plan -detailed-exitcode

CloudFormation has built-in drift detection for this:

aws cloudformation detect-stack-drift --stack-name myapp-prod

And CDK provides a command that under the hood calls the same CloudFormation check:

cdk drift

A good habit is to run such a check on a schedule (at night, for example) and notify the team if drift is found. Then manual edits don't quietly accumulate.

Where this applies

These topics surface exactly at the moment when infrastructure goes from a personal experiment to a team effort. Remote state and locking, as soon as more than one person works on the project. Separating environments, as soon as there's a real production that must not go down. A pipeline with a plan under review, when infrastructure changes have to be transparent and reversible, like ordinary code.

Typical beginner mistakes: leaving state as a local file and then spending a long time reconciling divergences; writing a password directly into the configuration and forgetting it settled into the state; confusing workspaces and applying a prod change in dev; putting a permanent AWS key in the CI settings. Each of these has a direct remedy from this article.

What to learn next: the internals of specific tools, Terraform, CloudFormation, CDK; the cloud basics, so you understand what exactly you're describing in code, AWS Fundamentals, networking, IAM; and delivery principles in general, branching and releases. If infrastructure rides into Kubernetes, Kubernetes Fundamentals and deploy and configuration will come in handy.