Primary tool: Terraform (HashiCorp) with Docker as the demonstration provider
Table of Contents
- Overview: Infrastructure as Code (IaC)
- Introduction to Terraform
- Managing Multiple Resources
- Reference Architecture
- GitOps and IaC in a CI/CD Pipeline
- Code File Reference
- Key Concepts and Best Practices
- Essential Terraform Commands
1. Overview: Infrastructure as Code (IaC)
What is IaC?
Infrastructure as Code (IaC) is the practice of managing and provisioning IT infrastructure (servers, networks, databases, containers, etc.) through machine-readable and human-readable configuration files, rather than through manual processes or graphical interfaces.
Core benefits of IaC:
- Reproducibility: The same code produces the same infrastructure every time — eliminates “snowflake” environments
- Versioning: Infrastructure is managed with Git, bringing history, code review, and rollback
- Automation: Integrates into CI/CD pipelines for human-free provisioning
- Collaboration: Teams share and review infrastructure changes like application code
- Idempotence: Applying the same configuration multiple times always produces the same result
Imperative vs Declarative Approaches
flowchart LR
subgraph IMP["IMPERATIVE — How to do it"]
direction TB
I1["Shell Script / Ansible\nAWS CLI / Azure CLI"]
I2["Step 1: Create VPC\nStep 2: Create subnet\nStep 3: Create EC2\nStep 4: Install nginx\nStep 5: Configure nginx"]
I3["Developer responsibility:\nManage operation order,\nhandle errors, manage state"]
I1 --> I2 --> I3
end
subgraph DEC["DECLARATIVE — What to achieve"]
direction TB
D1["Terraform / CloudFormation\nPulumi / Bicep"]
D2["Desired state:\nresource vpc {...}\nresource subnet {...}\nresource ec2 {...}\nresource nginx {...}"]
D3["Tool responsibility:\nDetermine actions,\nmanage order, manage state"]
D1 --> D2 --> D3
end
IMP --> COMP["Result: Provisioned Infrastructure"]
DEC --> COMP
| Criteria | Imperative | Declarative |
|---|---|---|
| Paradigm | Defines execution steps | Defines desired final state |
| Idempotence | Requires manual management | Built-in natively |
| State management | Script’s responsibility | Managed by tool |
| Learning curve | Familiar for devs/sysadmins | Requires learning the DSL |
| Logical flexibility | Very high (native conditions, loops) | Limited to tool constructs |
| Examples | Bash, Python, Ansible playbooks, AWS CLI | Terraform, CloudFormation, Pulumi, Bicep |
IaC Tool Comparison
| Tool | Publisher | Approach | Language | Scope | State Mgmt | Cloud |
|---|---|---|---|---|---|---|
| Terraform | HashiCorp | Declarative | HCL (DSL) | Multi-cloud | Local or remote state file | All (800+ providers) |
| OpenTofu | OpenTofu Foundation | Declarative | HCL | Multi-cloud | Terraform-compatible | All |
| Pulumi | Pulumi Corp | Declarative | TypeScript, Python, Go, C# | Multi-cloud | Pulumi Cloud or self-hosted | All |
| AWS CloudFormation | AWS | Declarative | JSON / YAML | AWS only | Managed by AWS | AWS |
| Azure Bicep | Microsoft | Declarative | Bicep (DSL) | Azure only | Managed by Azure | Azure |
| Ansible | Red Hat | Imperative (procedural) | YAML (playbooks) | Config mgmt + provisioning | Stateless (idempotent by convention) | Multi-cloud |
| Crossplane | CNCF | Declarative | YAML (K8s CRDs) | Multi-cloud | Kubernetes etcd | All |
2. Introduction to Terraform
What is Terraform?
Terraform is an Infrastructure as Code (IaC) tool developed by HashiCorp that allows defining cloud and on-premise resources in human-readable configuration files. These files can be versioned, reused, and shared between teams.
Key characteristics:
- Declarative syntax in HCL (HashiCorp Configuration Language)
- Supports all major cloud providers via a provider system
- Providers act as intermediaries between the Terraform executable and the target platform’s API
- All public providers available on the HashiCorp Registry (official + community providers)
The Provider System
graph TD
TF["Terraform Core\nmain executable"]
REG["HashiCorp Registry\nregistry.terraform.io\n800+ providers"]
TF -->|"1. terraform init\ndownload"| P
subgraph P["Provider System"]
direction LR
PD["kreuzwerker/docker\nv3.0.2"]
PA["hashicorp/aws\n~5.0"]
PAZ["hashicorp/azurerm\n~3.0"]
PG["hashicorp/google\n~5.0"]
end
PD -->|"API calls"| D["Docker API\nlocalhost"]
PA -->|"API calls"| A["AWS API\naws.amazon.com"]
PAZ -->|"API calls"| AZ["Azure API\nazure.microsoft.com"]
PG -->|"API calls"| G["GCP API\ngcloud.google.com"]
REG -.->|"provider source"| P
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "3.0.2"
}
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # ~> = compatible with 5.x
}
}
}
The Terraform Lifecycle
flowchart LR
INIT["terraform init\nDownloads declared\nproviders"]
PLAN["terraform plan\nCompares config vs state\nShows changes"]
APPLY["terraform apply\nExecutes the plan\nCreate/Modify/Destroy"]
DESTROY["terraform destroy\nDismantles all\ninfrastructure"]
STATE[("State File\nterraform.tfstate\nKnown real state")]
INIT --> PLAN
PLAN --> APPLY
APPLY -->|"writes state"| STATE
STATE -->|"compares"| PLAN
APPLY -->|"optional"| DESTROY
| Command | Role | Notes |
|---|---|---|
terraform init | Locates, downloads and prepares providers | Re-run after adding new providers |
terraform plan | Compares .tf files against state file, shows planned changes | + = create, ~ = update, - = destroy |
terraform apply | Executes the plan and makes API calls to modify resources | Asks for interactive confirmation |
terraform destroy | Dismantles all infrastructure defined in the project | Equivalent to terraform apply -destroy |
Demo: Basic Terraform Configuration
my-project/
├── main.tf # main configuration file
├── variables.tf # variable declarations (optional)
├── outputs.tf # values to export (optional)
├── .terraform/ # created by terraform init (don't commit)
├── .terraform.lock.hcl # provider version lock file (commit recommended)
└── terraform.tfstate # infrastructure state (don't commit)
The three fundamental blocks:
# 1. terraform block — global project configuration
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "3.0.2"
}
}
}
# 2. provider block — provider configuration
provider "docker" {
# host = "unix:///var/run/docker.sock" # default on Linux/macOS
}
# 3. resource blocks — resources managed by Terraform
resource "docker_image" "myapp" {
name = "nginx:latest"
}
resource "docker_container" "myapp_container" {
name = "web-server"
image = docker_image.myapp.image_id # cross-resource reference
ports {
internal = 80
external = 8080
}
}
Cross-resource reference: Terraform automatically builds the dependency graph and creates resources in the correct order.
3. Managing Multiple Resources
Resources vs Data Sources
| Type | Keyword | Lifecycle | Usage |
|---|---|---|---|
| Resource | resource | Fully managed by Terraform (creation → destruction) | Infrastructure Terraform provisions and manages entirely |
| Data Source | data | Read-only, not managed by Terraform | Existing resources whose properties to read without taking management |
graph LR
subgraph TF_MANAGED["Managed by Terraform\nterraform destroy = removes"]
R1["resource\ndocker_image.app"]
R2["resource\ndocker_container.app_container x3"]
R3["resource\ndocker_network.app_net"]
R4["resource\ndocker_container.proxy"]
end
subgraph EXISTING["Existing, read-only\nterraform destroy = keeps"]
D1["data\ndocker_image.nginx\npulled via docker pull nginx"]
end
R1 -->|"image_id"| R2
D1 -->|"id"| R4
R3 -->|"id networks_advanced"| R2
R3 -->|"id networks_advanced"| R4
Local Variables and Command-Line Variables
Local variables (locals):
locals {
http_port = 80
container_port = 8080
instance_count = var.environment == "prod" ? 5 : 2
}
resource "docker_container" "proxy" {
ports {
internal = local.http_port
external = local.http_port
}
}
Command-line variables (variable):
variable "environment" {
type = string
description = "Target environment (dev, staging, prod)"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
sensitive = false
default = "dev"
}
resource "docker_container" "app" {
count = var.environment == "prod" ? 5 : 1
name = "app-${var.environment}-${count.index}"
}
Ways to pass variables to Terraform:
# 1. Interactively (Terraform prompts)
terraform apply
# 2. Via command-line flag
terraform apply -var="environment=prod"
# 3. Via .tfvars file
terraform apply -var-file="prod.tfvars"
# 4. Via environment variable
export TF_VAR_environment=prod
terraform apply
The count Meta-Argument
resource "docker_container" "app_container" {
count = 3
name = "web-api-${count.index}" # web-api-0, web-api-1, web-api-2
image = docker_image.app.image_id
network_mode = "bridge"
networks_advanced {
name = docker_network.app_net.id
}
}
Resources are referenced as
docker_container.app_container[0],[1],[2]. If you modify an attribute that can only be set at creation time, Terraform will destroy and recreate the resource (shown asforces replacementin the plan).
4. Reference Architecture
Flask API + NGINX + Docker Network
graph TB
CLIENT["HTTP Client"]
CLIENT -->|"HTTP port 80"| NGINX
subgraph DOCKER["Docker Network: appnet"]
NGINX["nginx\nReverse Proxy / Load Balancer\ninternal port 80\nhost port 80"]
NGINX -->|"round-robin to /ping"| F0["web-api-0\nApp API\nport 8080"]
NGINX -->|"round-robin to /ping"| F1["web-api-1\nApp API\nport 8080"]
NGINX -->|"round-robin to /ping"| F2["web-api-2\nApp API\nport 8080"]
end
subgraph IMAGES["Docker Images"]
IMG1["app image\nTerraform resource\nmanaged and destroyed by TF"]
IMG2["nginx:latest\nTerraform data source\nread-only, preserved"]
end
IMG1 -.->|"image_id"| F0
IMG1 -.->|"image_id"| F1
IMG1 -.->|"image_id"| F2
IMG2 -.->|"id"| NGINX
Terraform Lifecycle Sequence
sequenceDiagram
actor Dev as Developer
participant CLI as Terraform CLI
participant STATE as State File tfstate
participant DOCKER as Docker API
Dev->>CLI: terraform init
CLI->>CLI: Downloads provider
CLI-->>Dev: Terraform initialized
Dev->>CLI: terraform plan
CLI->>STATE: Reads current state (empty on first run)
CLI->>CLI: Compares config vs state
CLI-->>Dev: Plan: +6 to add, 0 to change, 0 to destroy
Dev->>CLI: terraform apply
CLI-->>Dev: Shows plan and asks for confirmation
Dev->>CLI: yes
CLI->>DOCKER: Creates docker_network.app_net
CLI->>DOCKER: Creates docker_image.app (pull image)
CLI->>DOCKER: Creates docker_container.app_container[0]
CLI->>DOCKER: Creates docker_container.app_container[1]
CLI->>DOCKER: Creates docker_container.app_container[2]
CLI->>DOCKER: Creates docker_container.proxy
CLI->>STATE: Writes new state
CLI-->>Dev: Apply complete! Resources: 6 added
Dev->>CLI: terraform destroy
Dev->>CLI: yes
CLI->>DOCKER: Destroys containers and network
Note right of DOCKER: docker_image.nginx data source NOT destroyed
CLI->>STATE: Clears state file
CLI-->>Dev: Destroy complete!
5. GitOps and IaC in a CI/CD Pipeline
GitOps Workflow
flowchart TD
DEV["Developer\nModifies .tf files"] -->|"git commit + push\nbranch: feature/update"| PR
subgraph GIT["Git Repository"]
PR["Pull Request\nCode Review"]
PR -->|"Approved"| MAIN["Branch: main\nSingle source of truth"]
PR -->|"Rejected"| DEV
end
MAIN -->|"trigger push to main"| PIPELINE
subgraph PIPELINE["CI/CD Pipeline"]
direction TB
V["terraform validate\nSyntax check"]
P["terraform plan\nShow changes"]
APP["terraform apply\n--auto-approve"]
V --> P --> APP
end
APP -->|"provisions"| INFRA["Target Infrastructure"]
APP -.->|"remote state"| S3["Remote State\nS3 / Azure Blob\nTerraform Cloud"]
Terraform Pipeline in GitHub Actions
# .github/workflows/terraform.yml
name: Terraform IaC Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
terraform:
name: Terraform Plan & Apply
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Terraform Init
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -no-color
if: github.event_name == 'pull_request'
- name: Terraform Apply
run: terraform apply --auto-approve
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
6. Code File Reference
Basic Configuration (main.tf)
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "3.0.2"
}
}
}
provider "docker" {}
resource "docker_image" "app" {
name = "nginx:alpine"
}
resource "docker_container" "app_container" {
name = "web-server"
image = docker_image.app.image_id
ports {
internal = 80
external = 8080
}
}
Advanced Configuration (main.tf with load balancer)
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "3.0.2"
}
}
}
provider "docker" {}
locals {
http_port = 80
container_port = 8080
}
resource "docker_image" "app" {
name = "node:18-alpine"
}
data "docker_image" "nginx" {
name = "nginx"
}
resource "docker_network" "app_net" {
name = "appnet"
}
resource "docker_container" "app_container" {
count = 3
name = "web-api-${count.index}"
image = docker_image.app.image_id
network_mode = "bridge"
networks_advanced {
name = docker_network.app_net.id
}
}
resource "docker_container" "proxy" {
name = "nginx-proxy"
image = data.docker_image.nginx.id
ports {
internal = local.http_port
external = local.http_port
}
networks_advanced {
name = docker_network.app_net.id
}
volumes {
host_path = "/path/to/nginx.conf"
container_path = "/etc/nginx/nginx.conf"
}
}
NGINX Reverse Proxy Configuration (nginx.conf)
events {}
http {
upstream backend {
server web-api-0:8080;
server web-api-1:8080;
server web-api-2:8080;
}
server {
listen 80;
location /ping {
proxy_pass http://backend/ping;
}
location / {
proxy_pass http://backend/;
}
}
}
7. Key Concepts and Best Practices
| Concept | Best Practice |
|---|---|
| State file | Never commit terraform.tfstate to Git; use remote state (S3, Azure Blob, Terraform Cloud) |
| Lock file | Commit .terraform.lock.hcl to ensure consistent provider versions |
| Provider versions | Always pin versions: version = "3.0.2" or version = "~> 5.0" |
| Variable secrets | Use sensitive = true for passwords and tokens; never hardcode in .tf files |
| Resource naming | Use consistent, descriptive names: <type>_<purpose> |
| Remote state | Use remote state for teams — avoids conflicts and enables collaboration |
terraform fmt | Run regularly to keep HCL formatting consistent |
terraform validate | Run in CI pipeline before every plan |
| Data sources | Use for existing resources you don’t want Terraform to manage |
count vs for_each | Prefer for_each over count when order matters |
8. Essential Terraform Commands
# Project initialization
terraform init
# Format .tf files
terraform fmt
# Validate syntax
terraform validate
# Preview changes
terraform plan
# Apply changes
terraform apply
# Apply without confirmation prompt (CI/CD)
terraform apply --auto-approve
# Destroy all infrastructure
terraform destroy
# Destroy without confirmation prompt
terraform destroy --auto-approve
# State inspection
terraform state list
terraform state show docker_container.proxy
# Useful Docker commands for demos
docker pull nginx
docker ps
docker images
curl http://localhost/ping
Typical Development Cycle
flowchart TD
A["Write/modify\n.tf files"] --> FMT["terraform fmt\nHCL formatting"]
FMT --> VAL["terraform validate\nSyntax check"]
VAL --> B["terraform plan\nSee changes"]
B --> C{Changes\ncorrect?}
C -->|No| A
C -->|Yes| D["terraform apply\nApply changes"]
D --> E["Verify\ninfrastructure"]
E --> F{New\nmodifications?}
F -->|Yes| A
F -->|"No, temp env"| G["terraform destroy\nClean up"]
G -->|"Rebuild if needed"| B
Search Terms
devops · automation · infrastructure · ci/cd · terraform · configuration · iac · gitops · lifecycle · main.tf · nginx · pipeline · reference · resources