Write a small Terraform configuration, read its plan, apply it and destroy it, using the local Docker provider so nothing costs money.
Why it matters
Clicking through a cloud console works once. It does not work when you need the same network, database and load balancer in staging and production, when a colleague asks "who changed this firewall rule?", or when a region fails and you must rebuild everything. Infrastructure as code (IaC) describes infrastructure in text files that live in Git, get reviewed in pull requests and are applied by a tool. Your infrastructure gains the same history, review and repeatability as your application code.
Terraform is the most widely used IaC tool for cloud engineers, with providers for AWS, Azure, Google Cloud, Kubernetes, GitHub, DNS services and hundreds more. Since 2023 Terraform has been released under the Business Source License; OpenTofu is a community fork under an open-source license that uses the same language and commands (tofu instead of terraform). Everything in this module works with either.
The core idea: declare, plan, apply
Like Kubernetes, Terraform is declarative. You write what should exist; Terraform works out how to get there:
terraform initdownloads the providers (plugins that talk to an API) your code needs.terraform plancompares your code with what exists and prints the changes it would make:+create,~update in place,-/+replace,-destroy. Nothing changes yet.terraform applyshows the plan again and makes the changes after you typeyes.terraform destroyremoves everything the configuration manages.
Reading the plan carefully is the most important habit in IaC. A plan that says "1 to destroy" on a production database deserves a second look before anyone types yes.
A first configuration with no cloud bill
To learn safely, this lesson uses the community Docker provider, which manages containers on your own machine. The workflow is identical to managing cloud resources, but it costs nothing and cannot leave a forgotten server running.
# main.tf
terraform {
required_version = ">= 1.6"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
variable "external_port" {
type = number
default = 8080
description = "Port on your machine that forwards to the web server."
}
resource "docker_image" "nginx" {
name = "nginx:1.27-alpine"
keep_locally = false
}
resource "docker_container" "web" {
name = "tf-web"
image = docker_image.nginx.image_id
ports {
internal = 80
external = var.external_port
}
}
output "url" {
value = "http://localhost:${var.external_port}"
}
The language is HCL. The pieces:
| Block | Purpose |
|---|---|
terraform | Settings: required Terraform version and providers, pinned with ~> 3.0 (any 3.x) |
provider | Configures a provider. Docker needs no settings on macOS or Linux; on Windows set host = "npipe:////.//pipe//docker_engine" |
variable | An input, with a type, default and description |
resource | Something Terraform creates and manages. Its address is type.name, for example docker_container.web |
output | A value printed after apply and readable by other tools |
image = docker_image.nginx.image_id is a reference. It tells Terraform the container depends on the image, so the image is created first. Terraform builds a dependency graph from references and creates independent resources in parallel.
Running it
terraform init
terraform fmt # format the files consistently
terraform validate # check syntax and references
terraform plan # Plan: 2 to add, 0 to change, 0 to destroy.
terraform apply
curl http://localhost:8080 # nginx welcome page
terraform destroy
Change default = 8080 to 8081 and run terraform plan again. The plan shows -/+ for the container: a port mapping cannot be changed on a running container, so Terraform must replace it. The plan tells you this before anything happens, which is exactly why you read it.
You can also override variables without editing files: terraform plan -var="external_port=9090", or a terraform.tfvars file.
What goes in Git
Commit *.tf files and .terraform.lock.hcl (it records exact provider versions so everyone gets the same ones). Do not commit:
.terraform/
*.tfstate
*.tfstate.*
*.tfvars # if they contain secrets; commit an example file instead
crash.log
The terraform.tfstate file records what Terraform manages and can contain secrets in plain text. The next lesson covers where state should live instead.
Common mistakes
- Applying without reading the plan. Make "read the plan" part of every review; in CI, post the plan on the pull request.
- Editing resources by hand in the console. Terraform's view and reality drift apart, and the next apply may undo the manual change. Change the code instead.
- Unpinned providers. A new major version can change behaviour. Pin with
~>and update on purpose. - Hard-coding credentials in
providerblocks. Cloud providers read credentials from the environment or your CLI login; never put keys in.tffiles. - Forgetting
destroyon practice resources. On a real cloud, that is how free tiers turn into bills.
Quick recap
- IaC puts infrastructure in Git, with review and history.
init,plan,apply,destroy; always read the plan.- Resources, variables, outputs and references form a dependency graph.
- Commit
.tfand the lock file; never commit state or secrets.
Try it
The Do tab has you run this configuration end to end with Docker on your own machine.