Skip to content

Infrastructure as Code: Foundations

Linux in the Cloud showed a single instance configuring itself from a cloud-init user-data file. Infrastructure as Code (IaC) takes that same idea — infrastructure described in a file instead of clicked together by hand — and generalizes it to an entire fleet: every server, network rule, and DNS record an organization runs, described in version-controlled text, applied by a tool rather than a person following a runbook. This article covers the two dominant tool categories, the properties that separate good IaC from a fragile pile of scripts, and enough hands-on Ansible and Terraform to recognize both in the wild.

What You Will Learn

By the end of this article you will be able to:

  • explain the difference between declarative and imperative infrastructure management;
  • explain idempotency and why it is the property that makes IaC safe to re-run;
  • write and run a minimal Ansible playbook against a real host over SSH;
  • write and apply a minimal Terraform configuration and interpret its plan output;
  • reason about configuration drift and why IaC does not eliminate it automatically.

Declarative vs. Imperative: Two Ways to Describe a Change

An imperative approach describes the steps to reach a goal: "install nginx, then start it, then open port 80." A shell script written to provision a server — even a well-written one — is imperative by nature, because it executes a fixed sequence of commands regardless of the system's current state.

A declarative approach describes the desired end state and leaves the tool to figure out what steps, if any, are needed to reach it: "nginx should be installed and running; port 80 should be open." Ansible and Terraform are both declarative tools, and the practical difference shows up the second time you run them: a declarative tool checks the current state first and only makes the changes actually needed to close the gap, while a naive imperative script re-runs every command every time, regardless of whether anything has changed.

Imperative:  "run apt install nginx; run systemctl start nginx"
             — runs the same steps every time, whether or not needed

Declarative: "nginx: present, running"
             — checks current state; only acts if the state doesn't already match

Idempotency: The Property That Makes Re-Running Safe

Idempotency means applying an operation once has the same effect as applying it many times. This is the single most important property separating reliable IaC from a fragile shell script, because in real operations, a provisioning run is always re-run — after a partial failure, to add one new server to an existing fleet, or simply as a scheduled drift-correction pass — and a non-idempotent script behaves differently, often destructively, on the second run.

# Not idempotent: appends a duplicate line every single run
echo "127.0.0.1 myapp.local" >> /etc/hosts
# Idempotent: only adds the line if it is not already present
grep -qxF "127.0.0.1 myapp.local" /etc/hosts || \
  echo "127.0.0.1 myapp.local" >> /etc/hosts

The second version can be run a thousand times and the result is identical to running it once — this is precisely the discipline that Ansible modules (rather than raw shell commands) and Terraform's state-comparison model both build in automatically, which is the main reason to reach for one of them instead of a hand-rolled provisioning script the moment a task needs to run more than once.

Interview angle: "why not just write a Bash script for this?"

A reasonable-sounding challenge to IaC tooling is that Bash can do the same thing. The honest answer is that Bash can express the same steps, but idempotency, state tracking, and drift detection all have to be hand-built and hand-maintained in every single script — and in practice, they rarely are, because getting them right (as the /etc/hosts example above shows even for one trivial case) takes real, easy-to-skip discipline. Ansible and Terraform exist because someone already solved "check current state before acting" and "track what I created so I can change or remove exactly that" once, generically, so individual scripts do not each have to solve it again, inconsistently.

Ansible: Agentless, Push-Based Configuration

Ansible manages remote hosts over plain SSH — no agent needs to be installed on the managed machine beforehand, which is precisely the SSH access already covered in SSH Basics and SSH Key Authentication.

# inventory.ini
[web]
web-01 ansible_host=203.0.113.10
web-02 ansible_host=203.0.113.11

[web:vars]
ansible_user=deploy
# site.yml
- name: Configure web servers
  hosts: web
  become: true
  tasks:
    - name: Ensure nginx is installed
      apt:
        name: nginx
        state: present
        update_cache: true

    - name: Ensure nginx is running and enabled
      service:
        name: nginx
        state: started
        enabled: true

    - name: Deploy the site's index page
      copy:
        src: files/index.html
        dest: /var/www/html/index.html
        owner: www-data
        group: www-data
        mode: "0644"
ansible-playbook -i inventory.ini site.yml
PLAY [Configure web servers] **************************************

TASK [Ensure nginx is installed] ***********************************
changed: [web-01]
changed: [web-02]

TASK [Ensure nginx is running and enabled] *************************
ok: [web-01]
ok: [web-02]

PLAY RECAP ***********************************************************
web-01     : ok=3   changed=2   unreachable=0  failed=0
web-02     : ok=3   changed=2   unreachable=0  failed=0

The changed/ok distinction in the output is idempotency made visible: changed means Ansible found the system did not already match the declared state and made a change; ok means it already matched, and nothing happened. Running ansible-playbook a second time against the same hosts would show ok for every task, proving nothing was left to do — a live, per-task demonstration of the property described above, and the first thing worth checking after any playbook run: unexpected changed results on a re-run usually mean a task was written imperatively (an ad-hoc command: or shell: module call) rather than using a proper idempotent Ansible module.

become: true is Ansible's equivalent of prefixing every task with sudo — the same privilege-escalation model covered in Root and sudo, applied automatically across every managed host rather than typed by hand on each one.

Ansible has its own dry run, and it is the direct analog of terraform plan shown later in this article — a question interviewers ask precisely to check whether a candidate treats Ansible as a fire-and-forget script or as a reviewable change:

ansible-playbook -i inventory.ini site.yml --check --diff

--check runs every task in check mode: Ansible reports what would change without actually changing anything, printing the same changed/ok breakdown a real run would. --diff adds a line-by-line diff of every file a task would modify, so a copy or template task shows exactly which lines it is about to rewrite before it touches the host. Running --check --diff before applying a playbook to production is the same discipline as reading terraform plan before terraform apply. The one caveat worth stating: a task using the command:/shell: module cannot be simulated (Ansible has no way to know what an arbitrary command would do), so those tasks are skipped in check mode — another concrete reason to prefer proper modules over raw shell.

Terraform: Declarative Provisioning of Cloud Resources

Where Ansible typically configures software on machines that already exist, Terraform typically creates the machines (and networks, and DNS records, and storage) themselves, against a cloud provider's API.

# main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

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

  tags = {
    Name = "web-01"
  }
}
terraform init
terraform plan
Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami           = "ami-0abcdef1234567890"
      + instance_type = "t3.micro"
      + id            = (known after apply)
      ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

terraform plan is a dry run: it compares the configuration against Terraform's recorded state (a file tracking exactly which real-world resources Terraform previously created) and reports precisely what would change, without changing anything yet — always run and read this before apply, the same discipline as reviewing --dry-run output before any destructive command elsewhere in this course.

terraform apply
aws_instance.web: Creating...
aws_instance.web: Creation complete after 34s [id=i-0a1b2c3d4e5f6g7h8]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Running terraform plan again immediately afterward, with no configuration change, would report No changes. Your infrastructure matches the configuration. — the same idempotency property Ansible demonstrated, now applied to the resources themselves rather than to software installed on top of them.

Warning

Terraform's state file records exactly which real resources it manages, mapped to your configuration. Deleting or corrupting the state file does not delete the underlying cloud resources — it makes Terraform lose track of them, leaving them running, unmanaged, and billed, with no configuration pointing at them. Store state in a remote, versioned, locked backend (Terraform Cloud, an S3 bucket with DynamoDB locking, or the equivalent) for any team of more than one person, rather than a local file that can be lost or edited concurrently by two people at once.

Configuration Drift: What IaC Does Not Solve by Itself

Drift is the gap that opens up when a resource's real-world state diverges from what the IaC configuration says it should be — someone manually changed a firewall rule in a cloud console during an incident and forgot to update the Terraform file, or SSHed into a server and hand-edited a config file Ansible was supposed to manage. IaC tools do not prevent drift from happening; they detect it the next time they run, and — this is the part that catches teams off guard — a tool that detects drift will, by default, try to correct it back to what the file says, which can undo a legitimate emergency fix if the file was never updated to match.

terraform plan
  # aws_instance.web has been changed
  ~ resource "aws_instance" "web" {
      ~ instance_type = "t3.small" -> "t3.micro"
    }

Plan: 0 to add, 1 to change, 0 to destroy.

This output means someone (or something) resized the instance to t3.small outside of Terraform, and applying this plan would silently resize it back down to t3.micro — exactly the scenario where reading plan output carefully before apply prevents reverting a change that may have been made for a good reason.

Common Mistakes

  • Writing Ansible tasks with the shell:/command: module when a proper idempotent module exists. A shell: apt-get install -y nginx task reports changed on every single run, defeating the entire purpose of drift visibility; the apt: module used above reports changed only when something actually needed to happen.
  • Losing or not backing up Terraform state. As covered above, this does not delete the resources — it orphans them, often invisibly, until an unexpectedly large cloud bill reveals the problem.
  • Running terraform apply without reading terraform plan's output first. The plan is the dry run; skipping it on a shared, production-managing configuration is the direct equivalent of running a destructive command with no read-only check beforehand.
  • Manually fixing an incident in the cloud console and never updating the IaC configuration to match. This is the most common real-world source of drift, and it guarantees the next automated run either reverts the fix or fails confusingly when it encounters an unexpected state.

Exercises

  1. Write an Ansible playbook that ensures a package of your choice is installed and a corresponding service is running on a lab VM, run it twice, and confirm the second run reports ok (not changed) for every task.
  2. Deliberately write one Ansible task using shell: to install a package instead of the apt:/dnf: module, run the playbook twice, and observe that it reports changed both times. Explain why.
  3. Write a minimal Terraform configuration for a resource type you have access to test (a cloud instance, or even a local null_resource if no cloud account is available), run terraform plan and terraform apply, then manually change the resource outside of Terraform and run terraform plan again to observe the drift it reports.
  4. Explain, in a short paragraph, why a lost Terraform state file is a more dangerous incident than a lost Ansible playbook file, referencing what each tool actually tracks.

Summary

Infrastructure as Code replaces manually run, imperative provisioning steps with declarative descriptions of desired state, applied by tools — Ansible over SSH for configuring existing hosts, Terraform against provider APIs for creating the resources themselves — that check current state before acting and are therefore safe to re-run, a property called idempotency. Configuration drift is the gap between the declared state and reality that opens whenever a resource is changed outside the IaC tool's own workflow, and IaC only detects and reports this gap — reconciling it, especially after an intentional manual fix, still requires human judgment. Monitoring, Logging, and Automation picks up from here, covering how a fleet provisioned this way is observed and kept healthy on an ongoing basis.

References