🏗️

Infrastructure as Code Intermediate

Define infrastructure in version-controlled code: declarative provisioning, state, modules and immutable infra with Terraform.

19 lessons 57 quiz questions
Lessons & quizzes Certificate

📚 Lessons & quizzes

Each lesson ends with its own short quiz. Answer them as you go — score 90% across all lessons to earn your certificate.

1 What Infrastructure as Code is

Infrastructure as Code (IaC) is the practice of defining and managing infrastructure — servers, networks, load balancers, databases, DNS records — using machine-readable definition files rather than manual clicks in a web console or one-off commands typed at a terminal.

Instead of a human remembering the steps to build an environment, those steps live in text files that a tool reads and turns into real resources. The files become the single source of truth: to know how the environment is built, you read the code. To change it, you change the code and re-run the tool.

2 Benefits: repeatability, versioning and review

Because infrastructure is now code, it inherits the strengths of software engineering. Repeatability: the same files produce the same environment every time, so staging can mirror production and a new region can be stood up in minutes. Versioning: the files live in Git, giving a full history of who changed what and when, plus the ability to roll back.

Review: changes go through pull requests, so a teammate can inspect a proposed change before it touches real systems. Together these reduce the "works on my machine" problem and the risk of undocumented, hand-built "snowflake" servers that nobody can reproduce.

3 Declarative vs imperative

There are two broad styles of automation. An imperative approach lists the steps to perform: create this server, then attach this disk, then open this port. The author is responsible for the order and for figuring out what to do if something already exists.

A declarative approach describes the desired end state: "there should be two servers of this size with this disk and this firewall rule." The tool then compares the desired state with what currently exists and works out the actions needed to close the gap. Terraform is declarative; a hand-written shell script is typically imperative.

4 Idempotency

Idempotency means that applying the same configuration repeatedly produces the same result as applying it once. If the desired state already matches reality, running the tool again makes no changes — it does not create a second copy or error out.

This is what makes declarative IaC safe to re-run. You can apply the same configuration to a fresh environment to build it, or to an existing environment to confirm nothing has drifted, and the outcome converges to the same end state. Idempotency is the property that lets automation be run on a schedule without fear of compounding side effects.

5 Provisioning vs configuration management

Two related-but-distinct jobs are often confused. Provisioning creates the infrastructure itself: spin up virtual machines, networks, storage and managed services. Configuration management takes existing machines and installs packages, edits files and starts services so they are correctly set up.

IaC tools like Terraform focus on provisioning — bringing resources into existence and wiring them together. Tools like Ansible, Chef and Puppet focus on configuration management — shaping the inside of machines. The two are complementary: Terraform might create a server, then hand it to Ansible to install the application.

6 Terraform basics: providers and resources

Terraform is a popular open-source IaC tool. It talks to platforms through providers — plugins that know how to call a specific API (AWS, Azure, Google Cloud, Kubernetes, and many more). You declare which providers you need, and Terraform downloads them.

The building block you declare is a resource: a single piece of infrastructure such as a virtual machine, a bucket or a DNS record. Each resource has a type (which provider object it maps to) and a local name you use to refer to it elsewhere in your configuration.

# Declare a provider and a resource
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "eu-north-1"
}

resource "aws_s3_bucket" "logs" {
  bucket = "my-app-logs-bucket"
}

7 The HCL syntax

Terraform configurations are written in HCL (HashiCorp Configuration Language). The basic unit is a block: a block type, optional labels, and a body in braces containing arguments (name = value) and possibly nested blocks.

HCL supports strings, numbers, booleans, lists and maps. You reference other resources by writing type.name.attribute — for example aws_s3_bucket.logs.id. You can also interpolate expressions inside strings. Files end in .tf, and Terraform reads all .tf files in a directory together, regardless of their names.

# A block has a type, labels, and a body of arguments
resource "aws_instance" "web" {
  ami           = "ami-0abc123"
  instance_type = "t3.micro"

  tags = {
    Name = "web-server"
    Env  = "staging"
  }
}

# Reference another resource's attribute
output "web_id" {
  value = aws_instance.web.id
}

8 The core workflow: init, plan, apply, destroy

Terraform has a small, memorable command cycle. terraform init prepares a working directory: it downloads providers and sets up the backend. terraform plan shows a preview of the changes Terraform would make to reach the desired state — what it will add, change or destroy — without making them.

terraform apply executes the plan, actually creating or modifying resources (it asks for confirmation first). terraform destroy removes everything the configuration manages. A typical loop is: edit code, run plan to review, then apply.

# Prepare the directory (download providers, init backend)
terraform init

# Preview changes WITHOUT applying them
terraform plan

# Create or update resources to match the config
terraform apply

# Tear down everything this configuration manages
terraform destroy

9 The state file: what it is and why it matters

Terraform records what it has built in a state file (by default terraform.tfstate). The state is a mapping from the resources in your configuration to the real-world objects they correspond to — it remembers, for example, that aws_s3_bucket.logs is the actual bucket with a particular ID.

Without state, Terraform would have no way to know whether a resource already exists or needs creating. On each run it reads the state, refreshes it against reality, compares to your configuration, and computes the difference. The state can contain sensitive values, so it must be protected and never committed casually to a public repository.

10 Remote state and locking

Storing state on one engineer’s laptop does not scale: teammates cannot see it, and two people running apply at once could corrupt it. The solution is remote state — keeping the state file in a shared backend such as an S3 bucket, an Azure storage account, Terraform Cloud or a Google Cloud bucket.

A good backend also provides state locking: while one run holds the lock, others must wait, preventing concurrent modifications that would corrupt the state. Remote state also enables encryption at rest and access control, and lets one configuration read another’s outputs.

# Store state remotely in an S3 bucket with locking
terraform {
  backend "s3" {
    bucket         = "my-tf-state"
    key            = "prod/network.tfstate"
    region         = "eu-north-1"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
}

11 Variables and outputs

Hard-coding values everywhere is brittle. Input variables let you parameterise a configuration: declare a variable with variable, give it a type and optional default, and reference it as var.name. The same code can then build dev, staging and prod by supplying different values.

Outputs expose useful results after an apply — a server’s IP, a database endpoint — using output blocks. Outputs are printed at the end of a run and can be consumed by other configurations or scripts. Together, variables (inputs) and outputs define a configuration’s interface.

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

resource "aws_instance" "web" {
  ami           = "ami-0abc123"
  instance_type = var.instance_type
}

output "public_ip" {
  value = aws_instance.web.public_ip
}

12 Modules and reuse

A module is a reusable, self-contained package of Terraform configuration — a folder of .tf files with its own input variables and outputs. Every Terraform configuration is itself the root module; modules you call from it are child modules.

Modules let you define a pattern once (say, "a standard web server with its security group") and instantiate it many times with different inputs, rather than copying and pasting. They can come from a local path, a Git repository or the public Terraform Registry. Good modules have a clear, documented interface of inputs and outputs.

# Call a reusable module twice with different inputs
module "web_dev" {
  source        = "./modules/web-server"
  instance_type = "t3.micro"
  environment   = "dev"
}

module "web_prod" {
  source        = "./modules/web-server"
  instance_type = "t3.large"
  environment   = "prod"
}

13 The resource dependency graph

You do not tell Terraform the order to create things. Instead it builds a dependency graph from your configuration. When one resource references another’s attribute — say a subnet refers to a VPC’s ID — Terraform infers that the VPC must exist first. This is an implicit dependency.

From the graph Terraform decides what can be done in parallel and what must wait, and it does the same in reverse when destroying. When references are not enough to express ordering, you can add an explicit dependency with the depends_on argument.

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

# Implicit dependency: this subnet references the VPC's id
resource "aws_subnet" "app" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

14 Drift detection

Drift happens when the real infrastructure no longer matches what the Terraform configuration and state describe — usually because someone made a manual change in the console, or another process altered a resource out-of-band.

Terraform detects drift by refreshing: it reads the current real state of each managed resource and compares it to what is recorded. Running terraform plan will then show the difference, proposing changes to bring reality back in line with the code. The cure for drift is to make all changes through code, not by hand.

# Refresh state and show drift as a plan diff
terraform plan

# Re-apply to bring reality back in line with the code
terraform apply

15 Immutable infrastructure and cattle, not pets

With mutable infrastructure you upgrade servers in place — patch, tweak and reconfigure the same long-lived machines. Over time each one accumulates subtle, undocumented differences and becomes a fragile "snowflake."

Immutable infrastructure takes the opposite view: you never modify a running server. To change it, you build a fresh image, launch new instances from it, and discard the old ones. This is the "cattle, not pets" idea — servers are interchangeable and disposable, not lovingly hand-tended individuals. The result is more predictable, reproducible deployments and easy rollback by redeploying a previous image.

16 Data sources

Sometimes you need information about infrastructure that Terraform does not manage — the ID of the latest official OS image, an existing VPC created by another team, or the current AWS account ID. A data source lets a configuration read such information without creating or owning it.

You declare it with a data block and reference its attributes as data.type.name.attribute. Unlike a resource, a data source does not create anything — it only queries — so it appears in the plan as a read, not as an add, change or destroy.

# Look up an existing AMI without managing it
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]

  filter {
    name   = "name"
    values = ["ubuntu/images/*-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

17 Provisioners and why to avoid them

Provisioners run scripts or commands on a resource as part of its creation or destruction — for example a remote-exec that SSHes into a new VM to run a shell command. They look convenient for last-mile setup.

HashiCorp officially treats them as a last resort. They break the declarative model (they are imperative side effects Terraform cannot reason about), they are not idempotent, and a failed provisioner taints the resource. The better approach is to bake software into machine images beforehand, use cloud-init / user-data, or hand the host to a real configuration-management tool.

resource "aws_instance" "web" {
  ami           = "ami-0abc123"
  instance_type = "t3.micro"

  # Use only as a last resort
  provisioner "remote-exec" {
    inline = ["sudo systemctl restart nginx"]
  }
}

18 Workspaces and environments

You often need the same configuration deployed several times — dev, staging, prod — each with its own separate state. One built-in mechanism is workspaces: each workspace holds an independent state file for the same configuration, switchable with terraform workspace select.

Workspaces suit lightweight variations of one config. For strongly isolated environments, many teams instead prefer separate directories or root modules with their own backends and variable files, which makes the boundaries explicit and avoids accidentally applying prod changes from the dev workspace. Either way, the goal is the same code producing isolated, parameterised environments.

# Create and switch between isolated states
terraform workspace new staging
terraform workspace new prod

terraform workspace select prod
terraform workspace list

19 Other tools at a glance

Terraform is not the only IaC tool. AWS CloudFormation is AWS-native and declarative, using JSON or YAML templates; it manages resources as "stacks" but works only on AWS. Azure’s ARM templates (JSON) and the friendlier Bicep language play the same role for Microsoft Azure.

Pulumi takes a different angle: you write infrastructure in general-purpose programming languages such as TypeScript, Python or Go, while keeping a declarative, state-based model under the hood. The trade-offs are familiar: cloud-specific tools integrate tightly with one platform, while cloud-agnostic tools like Terraform and Pulumi span many providers.

🎓 Certificate of Completion

🔒 Complete every lesson quiz above with 90%+ to unlock your downloadable certificate.