Define your servers and services in code — repeatable, reviewable, version-controlled.
Module 10 · Terraform & Ansible. We provision the notes app from code. Free · Local.
Beginner+ IaC ~60 minBy the end of this module you will be able to:
init → plan → apply → destroyPrerequisites: Module 6 (Docker). We use Docker as a free, local stand-in for "infrastructure" so you need no cloud account.
Imagine setting up a server by hand: click "create VM" in a web console, SSH in, install packages, edit configs. Now do it again for staging. And production. And again when it breaks at 2 a.m. Every server ends up slightly different — a "snowflake" no one can reproduce.
Manual setup is slow, error-prone, undocumented, and impossible to review or roll back. When the person who built it leaves, the knowledge leaves too.
Infrastructure as Code means describing your infrastructure in text files you commit to Git. The same benefits you get from application code now apply to your servers:
IaC splits into two complementary tasks. Most teams use both.
| Terraform | Ansible | |
|---|---|---|
| Job | Provisioning — create the infra (servers, networks, DBs) | Configuration — set up what runs on it (packages, files, services) |
| Style | Declarative (describe the end state) | Mostly declarative tasks, run top-to-bottom |
| Language | HCL (HashiCorp Config Language) | YAML playbooks |
| Tracks state? | Yes — a state file | No — checks reality each run |
| Analogy | Builds the house | Furnishes the house |
You don't write steps ("create this, then that"). You describe the desired state and the tool figures out how to reach it — and what to change if reality drifts. Same mindset you learned with K8s manifests in Module 8.
Terraform talks to "providers" (AWS, Azure, GCP… and Docker). We'll use the Docker provider so you can provision the notes app on your own machine — the exact same skills transfer to a cloud provider, just by swapping the provider block. Make a new folder terraform/.
Grab it from developer.hashicorp.com/terraform/install, then verify:
main.tfThis is the heart of the module. It declares a provider, a variable, two resources (the image + a running container), and an output. Read every block — we explain them next.
This references notes-app:1.0 from Module 6. If you don't have it, run docker build -t notes-app:1.0 . in your app folder first.
init reads your config and downloads the Docker provider plugin into the folder. Run it once per project (and after adding providers).
This is Terraform's superpower: it shows exactly what it will create, change, or destroy before touching anything. Always read the plan.
You'll see + create for the image and container — a dry run with no surprises.
Terraform creates the container and prints the url output. Open http://localhost:8080 — the notes app, provisioned entirely from code. ✅ Confirm with docker ps.
You didn't run a single docker run. You described the desired state and Terraform built it. Swap the provider block for aws and the same workflow provisions real cloud servers.
Edit main.tf and change default = 8080 to default = 9090, then plan again:
Terraform records what it created in a terraform.tfstate file. That's how it knows the difference between "what exists" and "what you want," and only changes the gap. Never edit state by hand, and never commit it if it holds secrets (add it to .gitignore; teams use remote state).
One command tears down everything Terraform created — no leftovers, no forgotten resources running up a cloud bill.
| Block | What it does |
|---|---|
terraform { } | Settings — which providers and versions are required. |
provider | The platform you're targeting (docker, aws, google…). |
resource | A thing to create — an image, container, server, network. |
variable | An input you can change without editing the core logic. |
output | A value to print after apply (URLs, IPs, IDs). |
terraform.tfstate | Auto-generated record of what Terraform manages. Don't edit by hand. |
image = docker_image.notes.image_id means "use the image this other resource created." Terraform reads these references to figure out the correct order automatically — you never specify it.
Terraform built the box; now Ansible configures what's inside it — installing packages, writing config files, starting services. We'll run a playbook against your own machine (localhost) so there's nothing to provision. Make an ansible/ folder.
app.conf.j2Ansible uses Jinja2 templates so config can vary by environment. The {{ }} values are filled in from variables.
playbook.ymlA playbook is a list of tasks. Each task uses a module (file, template…) to bring the system to a desired state.
Check the result: cat ~/notes-config/app.conf shows your rendered config. The first run reports changed=2. Now run the exact same command again:
The second run changes nothing because the system is already in the desired state. This is idempotency — you can safely run a playbook any number of times and only the gaps get fixed. It's what makes config management trustworthy.
We used Docker and localhost to stay free — but the skills are identical for the cloud:
| Today (local) | In the cloud (same workflow) |
|---|---|
provider "docker" | provider "aws" / "google" / "azurerm" |
docker_container resource | aws_instance (an EC2 server) resource |
Ansible against localhost | Ansible against an inventory of real servers over SSH |
init → plan → apply | Exactly the same loop |
You've learned the workflow, which is the hard part. Targeting AWS instead of Docker is mostly a matter of credentials and different resource names — we do exactly that in the optional cloud bonus (Module 13).
The commands you'll use every day. Bookmark this.
| Command | What it does |
|---|---|
terraform init | Download providers, prep the folder |
terraform fmt | Auto-format your .tf files |
terraform validate | Check the config for errors |
terraform plan | Preview changes without applying |
terraform apply | Create/update infrastructure |
terraform destroy | Tear everything down |
terraform show | Inspect current state |
terraform output | Print output values |
| Command | What it does |
|---|---|
ansible-playbook play.yml | Run a playbook |
ansible-playbook play.yml --check | Dry run (preview changes) |
ansible-playbook play.yml -i hosts | Run against an inventory file |
ansible all -m ping | Test connectivity to hosts |
ansible-galaxy install <role> | Install a reusable role |
| Symptom | Likely cause & fix |
|---|---|
Could not load plugin on init | Typo in the provider source, or no internet. Re-check the required_providers block. |
| image not found on apply | Build it first: docker build -t notes-app:1.0 . |
| port already allocated | Another container uses that port — change app_port and re-apply. |
| Terraform wants to destroy unexpectedly | State drifted (you changed things by hand). Read the plan carefully before saying yes. |
| Ansible YAML error | Indentation — 2 spaces, no tabs (same rule as Compose/K8s). |
Playbook always shows changed | A task isn't idempotent (e.g. raw command). Prefer purpose-built modules. |
Stretch your understanding before moving on:
variable for the container name and reference it with var..docker_container resource so Terraform provisions the whole stack (like Compose did).terraform fmt and terraform validate on your config.app_env isn't set, using ansible.builtin.assert.Provision infrastructure declaratively with Terraform (init/plan/apply/destroy + state), and configure systems idempotently with Ansible playbooks. Your environment is now as repeatable and reviewable as your code.
Next up: Module 11 — Monitoring & Logging, where you'll see what your app is actually doing in production with Prometheus and Grafana.