The previous lesson ended by pointing at an uncomfortable crack: cd.yml deploys on its own, but it takes for granted that there is a reservalia-dev cluster, a reservalia-api service, an RDS database, an ALB and some IAM roles that Nuria created by hand in the AWS console months ago. The only source of truth about how staging and prod differ is whatever sits inside AWS, and nobody has read it end to end. This lesson closes that crack: we are going to see why an automated deployment on top of hand-crafted infrastructure is still fragile, what Infrastructure as Code actually is, and how Reservalia's infra/ module is written once in Terraform and instantiated three times so that the three environments come out of literally the same code, with their differences written as explicit values rather than mysteries.
Contents
- The problem: configuration drift and "it worked in staging"
- What Infrastructure as Code is and its four principles
- A tour of the tools
- Reservalia's
infra/module in Terraform - One module, three environments: differences as values
- Remote state and locking: why local state is an accident waiting to happen
- The infrastructure pipeline:
planon the PR,applyonmain - Real risks: destructive recreations, plans that lie and excessive permissions
- Ephemeral preview environments per pull request
- Common Mistakes and Tips
- Exercises
- Conclusion
- The problem: configuration drift and "it worked in staging"
Let us do the uncomfortable exercise. Marta asks in a meeting: "How does staging differ from prod?". Nuria replies that prod has four tasks and is Multi-AZ; Diego thinks staging's log retention is a week, "or three days, I do not remember". Marta presses on: "And the database security group? And the ALB idle timeout?". Silence.
That is configuration drift: the accumulated, undocumented difference between environments that ought to resemble each other. It does not appear all at once; it is built out of urgent fixes. One Tuesday eight months ago, prod was timing out and Nuria raised the ALB idle_timeout from 60 to 120 seconds from the console. It worked, the incident was closed and nobody touched staging. Today there is a slow request that cuts off after 60 seconds in staging and does not in prod. Or the other way round, which is worse.
The classic outcome has a name of its own: "it worked in staging". And it drags along a consequence that breaks the whole module: if staging is not equivalent to prod, then going through staging authorises nothing. Requirement 1 from lesson 03-01 — a test suite you trust — collapses, not because the tests are bad, but because they run somewhere that does not represent the destination. Hand-made infrastructure also has four defects that no amount of discipline corrects: it is not reviewable (nobody can comment on a change in a pull request before it happens), it is not reproducible (recreating the environment in another region means rebuilding it from memory), it is not auditable (CloudTrail tells you who touched something, but not why or against what design) and it is not reversible (there is no "previous state" to go back to: the console has no git revert).
Nuria: "A deployment you cannot undo in five minutes is not a deployment, it is a bet. And an environment you cannot recreate in an hour is not an environment either: it is a relic."
- What Infrastructure as Code is and its four principles
Infrastructure as Code (IaC) means describing infrastructure resources — networks, databases, clusters, permissions — in versioned text files, and letting a tool create and maintain them. Infrastructure becomes a software artifact: it is reviewed in a pull request, tested, versioned and reverted.
Principle 1: declarative, not imperative. An imperative approach describes steps ("create a cluster, then add a service, then scale the tasks up to four"). A declarative approach describes the result ("there is a cluster with a four-task service") and the tool works out what to do to get there from wherever it currently is.
Imperative (a bash script with the aws cli) |
Declarative (Terraform) | |
|---|---|---|
| What you write | The sequence of steps | The desired final state |
| If it runs twice | It usually fails or duplicates | It does nothing: it already matches |
| If somebody changed something by hand | The script has no idea | It detects it and corrects it |
| Readability | You have to simulate the execution in your head | It reads like a description of the system |
Principle 2: idempotency. It already came up in 03-02 when we talked about deployments, and here it is even more central: applying the same configuration ten times leaves the system the same as applying it once. That is what lets you run terraform apply on every merge without fear.
Principle 3: state. In order to know what to change, the tool needs to remember what it created. Terraform keeps a state file that maps each block of code to its real resource in AWS. It is not an implementation detail: it is the most delicate asset in the system, which is why it gets its own section (section 6).
Principle 4: plan before applying. Before touching anything, the tool computes and shows the difference between what exists and what is being asked for. That plan is what turns an infrastructure change into something reviewable, and it is the piece that hooks IaC into the pipeline.
- A tour of the tools
| Tool | Model | Language | Scope | When it fits |
|---|---|---|---|---|
| Terraform / OpenTofu | Declarative, with state | HCL | Multi-provider | The de facto standard; OpenTofu is the open fork after the licence change |
| AWS CloudFormation | Declarative, state managed by AWS | YAML/JSON | AWS only | If you never leave AWS and do not want to manage state |
| AWS CDK | Imperative that generates CloudFormation | TypeScript, Python… | AWS only | Teams that prefer a real language and their own abstractions |
| Pulumi | Declarative with a general-purpose language | TypeScript, Go… | Multi-provider | Like CDK but without tying yourself to AWS |
| Ansible | Idempotent procedural, stateless | YAML | Configuration inside machines | Servers that already exist; it complements rather than replaces |
The distinction that confuses people most at first: provisioning (creating the machine, the network, the database) is not the same as configuring (installing packages inside a machine that already exists). Terraform does the former; Ansible, the latter. Reservalia barely needs the latter, because its workloads run in containers and the "machine configuration" is already in the Dockerfile from 02-03. Reservalia chooses Terraform: it is multi-provider, its module ecosystem is the widest and the team already knows how to read HCL. The code in this lesson works just the same with OpenTofu by changing the binary.
- Reservalia's
infra/ module in Terraform
infra/ module in TerraformThe structure Nuria creates in the monorepo:
infra/
├── modules/environment/ # ONE module: main.tf · variables.tf · outputs.tf
└── environments/
├── dev/main.tf # instantiates the module with the dev values
├── staging/main.tf
└── prod/main.tfA Terraform module is a folder of parameterisable code, like a function: infra/modules/environment/ describes "a Reservalia environment" and does not know whether it is dev or prod, because it receives that information through variables.
# infra/modules/environment/variables.tf
variable "environment" {
description = "Environment name: dev, staging or prod"
type = string
validation { # 1
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "The environment must be dev, staging or prod."
}
}
variable "task_count" { type = number }
variable "task_cpu" { type = number, default = 512 }
variable "task_memory" { type = number, default = 1024 }
variable "db_class" { type = string }
variable "db_multi_az" { type = bool, default = false }
variable "log_retention" { type = number, default = 7 }
variable "postgres_version" { type = string, default = "16.3" } # 2- The
validationblock turns a typo into aplanfailure with a clear message, instead of creating an environment calledprodd. postgres_versionhas a default value and no environment changes it. That is no accident: it is requirement 3 from 03-01 written in code. If tomorrow somebody wanted to try PostgreSQL 17 instaging, they would have to write it explicitly in a pull request, and the review would ask why.
The body of the module, with the essentials of a Reservalia environment:
# infra/modules/environment/main.tf
locals {
name = "reservalia-${var.environment}" # 1
tags = { Project = "reservalia", Environment = var.environment, Managed = "terraform" } # 2
}
resource "aws_ecs_cluster" "this" {
name = local.name
tags = local.tags
}
resource "aws_db_instance" "postgres" {
identifier = "${local.name}-db"
engine = "postgres"
engine_version = var.postgres_version
instance_class = var.db_class
allocated_storage = 20
multi_az = var.db_multi_az
backup_retention_period = var.environment == "prod" ? 30 : 1 # 3
deletion_protection = var.environment == "prod" # 4
skip_final_snapshot = var.environment != "prod"
tags = local.tags
}
resource "aws_ecs_service" "api" {
name = "reservalia-api" # 5
cluster = aws_ecs_cluster.this.id
desired_count = var.task_count
launch_type = "FARGATE"
task_definition = aws_ecs_task_definition.api.arn
lifecycle { ignore_changes = [task_definition, desired_count] } # 6
}
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/${local.name}/api"
retention_in_days = var.log_retention
}localsare values computed once and reused; they save repeating the name interpolation in twenty places.- The
Managed = "terraform"tag looks decorative and is one of the most useful: it lets you audit which resources in the account are managed by code and which are still hand-crafted. - A conditional (
condition ? value_if : value_else) expresses a real difference between environments without duplicating the resource: 30 days of backups in production, 1 everywhere else. deletion_protectioninprodstops an accidentalterraform destroywiping out the database of the 340 businesses. It is a cheap and mandatory safety net.- The service name is identical across the three environments (
reservalia-api), exactly ascd.ymlfrom 03-02 assumes; what changes is the cluster. ignore_changes = [task_definition, desired_count]is the most important line in the file and the one most teams forget. Terraform creates the service, but the thing that changes the deployed version iscd.yml. Without this line, everyterraform applywould return the service to the image written in the infrastructure code, undoing the last deployment. The general rule: Terraform owns the shape of the environment; the CD pipeline owns the version running inside it.
- One module, three environments: differences as values
Here is the central idea of the lesson. The three environments are three calls to the same module:
# infra/environments/dev/main.tf
module "environment" {
source = "../../modules/environment"
environment = "dev"
task_count = 1
db_class = "db.t4g.micro"
log_retention = 3
}
# infra/environments/prod/main.tf
module "environment" {
source = "../../modules/environment"
environment = "prod"
task_count = 4
task_cpu = 1024
task_memory = 2048
db_class = "db.m6g.large"
db_multi_az = true
log_retention = 30
}Compare this with the scene in section 1. The question "how does staging differ from prod?" no longer requires memory or archaeology in the console: it is a diff of two ten-line files.
| Parameter | dev |
staging |
prod |
|---|---|---|---|
task_count |
1 | 2 | 4 |
db_class |
db.t4g.micro |
db.t4g.small |
db.m6g.large |
db_multi_az |
false |
false |
true |
log_retention |
3 | 7 | 30 |
postgres_version |
16.3 | 16.3 | 16.3 |
| Network topology, security groups, ALB, IAM | identical | identical | identical |
The differences are six numbers and a boolean, all of them justifiable by cost or resilience, and none of them capable of changing the behaviour of the software. That is what "equivalent environments" in requirement 3 means. And there is a valuable side effect: any adjustment Nuria makes in the module — a PostgreSQL parameter, an ALB timeout — reaches all three environments at once, so drift can no longer accumulate in silence.
- Remote state and locking: why local state is an accident waiting to happen
The terraform.tfstate file records the correspondence between each block of code and the real resource. If it lives on Nuria's laptop three things happen, all bad: nobody else can apply changes without it; if the laptop is lost, Terraform no longer knows those resources are its own and will try to create them again; and if Nuria and Diego apply at the same time, each writes a state that ignores what the other did. On top of that, the state contains sensitive values in the clear, such as the generated RDS password: it must not end up in the repository. The solution is a remote backend with locking:
# infra/environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "reservalia-tfstate" # 1
key = "environments/prod/terraform.tfstate" # 2
region = "eu-west-1"
dynamodb_table = "reservalia-tflock" # 3
encrypt = true # 4
}
}- An S3 bucket with versioning enabled: if an
applycorrupts the state, you recover the previous version. - One key per environment: three independent states. That is what guarantees a mistake applying
devcannot touchprodresources, because thedevstate does not even know about them. - The DynamoDB table implements the lock: whoever starts an
applytakes the lock and everyone else waits with an explicit message instead of treading on each other. Andencrypt = trueencrypts at rest a file that, as we have just said, contains secrets.
- The infrastructure pipeline:
plan on the PR, apply on main
plan on the PR, apply on mainWith remote state, infrastructure can enter the pipeline with the same flow as code: proposal, review, application.
flowchart TD
A["PR touching infra/**"] --> B["infra.yml · plan job<br/>read-only role"]
B --> C{"Plan commented on the PR<br/>is anything destroyed"}
C -- "unwanted changes" --> A
C -- "approved" --> E["Merge to main"]
E --> F["apply job<br/>dev and staging: automatic"]
F --> G{"environment: prod<br/>required reviewer"}
G -- approves --> H["terraform apply on prod"]
H --> I["cd.yml deploys onto<br/>known infrastructure"]
# .github/workflows/infra.yml
name: Infra
on:
pull_request: { paths: ['infra/**'] } # 1
push: { branches: [main], paths: ['infra/**'] }
permissions: { id-token: write, contents: read, pull-requests: write } # 2
concurrency: { group: infra-${{ github.ref }}, cancel-in-progress: false }
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04
strategy:
matrix: { environment: [dev, staging, prod] } # 3
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.8.5 } # 4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::…:role/reservalia-terraform-plan # 5
aws-region: eu-west-1
- run: |
cd infra/environments/${{ matrix.environment }}
terraform init
terraform plan -no-color -out=plan.bin
terraform show -no-color plan.bin > plan.txt
- uses: actions/github-script@v7 # 6 · comments plan.txt on the PR
with: { script: 'github.rest.issues.createComment({ …, body: readPlan() })' }paths: ['infra/**']stops the infrastructure pipeline running on every API code change, andpull-requests: write(2) allows the plan to be commented on the PR.- A matrix runs the plan for all three environments: the reviewer sees the full impact of the change, not just that of the environment that was edited.
- A pinned Terraform version. Two different versions can produce different plans from the same code; in infrastructure that is unacceptable.
- A read-only role for the plan. The job runs with code coming from a pull request and does not need write access: granting it would be handing production to anyone who opens a branch.
- The plan as a comment is the heart of the flow: it turns an infrastructure change into something that gets read and discussed before it happens.
The apply job runs on main, with environment: dev/staging automatic and environment: prod with a required reviewer — exactly the mechanism from 03-02 — running terraform apply -auto-approve with the reservalia-terraform-apply role.
- Real risks: destructive recreations, plans that lie and excessive permissions
Risk 1 (the most expensive): changes that recreate resources holding data. Some attributes cannot be modified in place. Changing an RDS instance's identifier does not rename it: it destroys it and creates another, empty one. The plan says so, but you have to know how to read it:
-/+ resource "aws_db_instance" "postgres" { # must be replaced
~ identifier = "reservalia-prod-db" -> "reservalia-prod-postgres" # forces replacementThe two symbols to look for every single time are -/+ and forces replacement. Reservalia adopts three defences: deletion_protection and prevent_destroy in the database's lifecycle, mandatory human review of the prod plan, and a team rule — any PR whose plan contains forces replacement needs Nuria's approval, no exceptions.
Risk 2: the plan does not match the apply. Between Monday's pull request plan and Wednesday's apply somebody may have touched something by hand, or another infrastructure PR may have been merged. The plan is a snapshot, not a contract. Mitigations: apply the saved plan.bin file rather than recomputing (Terraform aborts if the state changed), concurrency to serialise, and a weekly scheduled plan that detects drift. Risk 3: the pipeline with excessive permissions. The apply role needs to create and delete infrastructure, so it is the most powerful credential in the organisation: separate plan (read) from apply (write), narrow the OIDC sub condition to the specific environment with no wildcards — as we saw in 03-02 — and limit the role to the services it really uses. The pipeline's attack surface is covered in depth in lesson 04-03, Security in CI/CD.
- Ephemeral preview environments per pull request
When an environment is a call to a module, creating a new one stops being a project and becomes a variable. That enables something previously unthinkable at Reservalia: one environment per pull request, created when it is opened and destroyed when it is closed.
module "environment" {
source = "../../modules/environment"
environment = "pr-${var.pr_number}" # cluster reservalia-pr-482
task_count = 1
db_class = "db.t4g.micro"
}Marta can test the feature at https://pr-482.reservalia.com before merging, and the designer can see it without installing anything. Three cautions: always destroy them when the PR closes (an on: pull_request: types: [closed] job plus a nightly sweep of any that survive more than 72 hours), never use real data in them, and watch the cost, which is linear in the number of open PRs. A full environment module with RDS may be too expensive; many teams use a lightweight variant with a shared database and one schema per PR. And it is worth mentioning a related family: GitOps (Argo CD, Flux) takes this idea to the extreme, with an agent inside the cluster that watches the repository and continuously reconciles the real state with the declared one, rather than a pipeline pushing changes; it is a different model, very widespread in Kubernetes, and it is covered in lesson 06-05.
Common Mistakes and Tips
Mistake 1: committing terraform.tfstate to the repository. It contains secrets in the clear and does not solve concurrent work. Add it to .gitignore and use a remote backend from day one. Mistake 2: writing three copies of the code, one per environment, which is what produces the most drift, because an urgent fix gets applied to one file and not to the other two.
Mistake 3: running apply from your laptop. Even with remote state, a local apply leaves no reviewable trace and runs no checks. Reservalia's rule: if it did not go through infra.yml, it does not exist. Mistake 4: forgetting ignore_changes on the task definition, whose symptom is baffling: an apparently unrelated infrastructure change returns production to a version from three weeks ago.
Mistake 5: not reading the plan. A 400-line plan nobody looks at is worse than not having one, because it gives a feeling of control; always look for forces replacement and the final destroy count. Tip 1: start by importing what already exists. terraform import (or import blocks) brings hand-crafted infrastructure into the state without recreating it. Nuria did exactly that: she imported prod, adjusted the code until terraform plan said "No changes", and only then did she trust the module. Tip 2: pin the versions of the binary and the providers (required_providers) and commit .terraform.lock.hcl to the repository. Tip 3: schedule a weekly plan across the three environments; if a diff shows up without anybody having touched the code, somebody has gone back to the console.
Exercises
Exercise 1
Nuria proposes speeding things up like this: "In dev I use PostgreSQL 14 because the instance is cheaper, and in prod I keep 16." Explain why this decision breaks part of module 3 and which differences are acceptable between environments.
Exercise 2
A pull request changes db_class from db.t4g.small to db.t4g.medium in staging. The plan shows ~ instance_class and, further down, Plan: 0 to add, 1 to change, 0 to destroy. Another PR changes the database identifier and shows Plan: 1 to add, 0 to change, 1 to destroy. Explain the difference and what you would do in each case.
Exercise 3
Diego says: "Since terraform apply runs in the pipeline anyway, let us drop the manual prod approval: after all, the plan was already reviewed in the PR." Give two arguments against and one condition under which it would be reasonable.
Solutions
Solution 1. It breaks requirement 3 from 03-01, equivalent environments, and with it the value of the entire chain from 03-02. Between PostgreSQL 14 and 16, the query planner, the available functions and type behaviours all change: a calculateSlots query may work in dev and fail or degrade in prod, and — worse — the other way round, in which case dev would produce false positives nobody would know how to interpret. The rule: differences of scale are acceptable (task count, instance class, Multi-AZ, log retention, data volume), because they affect cost and resilience but not the functional behaviour of the software; differences of version or topology are not acceptable (database engine, Node version, presence of an ALB, CPU architecture), because they change what the code does. If the cost of dev is a worry, the right lever is to lower the instance class or switch the environment off overnight, not to change the engine version.
Solution 2. The first plan is an in-place modification: ~ indicates the attribute is changed without destroying the resource. AWS will apply the class change with a brief restart, but the data is preserved. It is a routine change; it is worth applying it in a low-traffic window if it is prod. The second is a destructive replacement: 1 to destroy on a database means Terraform will delete the current instance — with all its data — and create an empty one with the new name. In staging that would mean losing the anonymised copy; in prod, losing the data of the 340 businesses. Action: do not merge. If the rename is genuinely necessary, the safe path is to change the resource's address in the state with a moved block or terraform state mv where applicable, or simply to give up the cosmetic change. And the prevent_destroy in the lifecycle should have made the plan fail before it ever reached review: if it did not, that defence is missing.
Solution 3. Against: (1) the PR plan and the later apply are not the same computation — other PRs may have been merged in between, or the state may have changed — so approving Monday's plan is not the same as approving Wednesday's application; (2) the PR reviewer reviews code, and whoever approves the prod environment reviews timing: there may be a campaign running, an open incident or a deployment in progress that make touching infrastructure right now unwise, information that is not in the diff. It would be reasonable to remove it once the pipeline applies the very same approved plan.bin (Terraform aborts if the state changed), an automatic check exists that fails on any forces replacement or destroy, and the history shows that the human approval has not rejected anything over a significant number of applications — the same criterion as in 03-01.
Conclusion
The crack we started with is closed. Reservalia's three environments are no longer three hand-crafted things that resemble each other by coincidence: they are three calls to the same module in infra/modules/environment/, and their differences are six numbers and a boolean written in a file anyone can read in twenty seconds. The question "how does staging differ from prod?" finally has an answer that does not depend on anybody's memory. The ideas worth taking away: configuration drift accumulates through urgent fixes and destroys the value of staging as an authorisation for production; IaC is declarative, idempotent, stateful and plan-first, and that plan is what makes an infrastructure change reviewable; remote state with locking is non-negotiable as soon as more than one person is involved; the pipeline separates plan with read permissions on the PR from apply with approval on main; you must always read the plan looking for forces replacement; and Terraform owns the shape of the environment while CD owns the version running inside it — hence ignore_changes. As a bonus, ephemeral per-pull-request environments become possible.
Of the five requirements, Reservalia is now at three: reliable tests, an immutable artifact and now equivalent environments. But cd.yml still deploys to prod in the simplest way possible, replacing tasks with no further control, and there is still no prod in the pipeline. The next lesson, Deployment Strategies, deals with exactly that: we will look at recreate, rolling update, blue-green, canary, A/B testing and shadow, with their cost, their risk and how easy they make going back; we will build a fine-grained rolling update in ECS and a canary by ALB weight at 10%; we will distinguish liveness from readiness and see why a health check that returns 200 without checking anything is worse than having none at all; and we will accept the requirement all these strategies share: that two versions of the software are going to coexist, and they have to understand each other.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
