Infrastructure as Code
- Infrastructure as Code
The environment as a reviewable artifact
Infrastructure as Code (IaC) is the practice of defining and managing infrastructure — servers, networks, databases, DNS records — in version-controlled text files rather than through manual console clicks. Changes go through code review; the current state of the infrastructure is always readable from the repository.
Two distinct IaC categories appear in the DevOps roadmap:
- Provisioning tools (Terraform, Pulumi, AWS CDK, CloudFormation) — create and destroy infrastructure resources
- Configuration management tools (Ansible, Chef, Puppet) — configure the software and state inside already-running machines
ℹ️ The security payoff is auditability: when the config is the source of truth, any drift between it and the running system is detectable, and every change is a reviewed commit with an author and a diff. This is the Week 1 foundation the rest of the course hardens — you cannot trust an environment you built by hand.
Terraform
Terraform is the dominant open-source provisioning tool. It uses HashiCorp Configuration Language (HCL) to declare the desired state of infrastructure. Terraform computes a diff between the current state and the desired state, then applies only the necessary changes.
The course repo already uses Terraform for Proxmox VMs in secdevops/kali/. This section covers the broader concepts and patterns.
Installation
# Install via package manager (Debian/Ubuntu)
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor \
-o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Verify
terraform version
Core workflow
terraform init # download provider plugins and set up backend
terraform validate # check HCL syntax and internal consistency
terraform plan # show what changes will be made (dry run)
terraform apply # apply the plan (prompts for confirmation)
terraform destroy # destroy all resources in the configuration
HCL structure
# main.tf — a minimal Linux VM on AWS
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Store state remotely (S3 + DynamoDB for locking)
backend "s3" {
bucket = "my-terraform-state"
key = "prod/main.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
}
}
provider "aws" {
region = var.aws_region
}
# Variable definitions
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
# Data source: look up the latest Debian AMI
data "aws_ami" "debian" {
most_recent = true
owners = ["136693071363"] # Debian official AWS account
filter {
name = "name"
values = ["debian-12-amd64-*"]
}
}
# Resource: EC2 instance
resource "aws_instance" "web" {
ami = data.aws_ami.debian.id
instance_type = var.instance_type
key_name = "my-keypair"
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "web-server"
Environment = "production"
}
}
# Resource: security group
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTP, HTTPS, and SSH"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Output values
output "instance_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
State management
Terraform stores the known state of all managed resources in a state file (terraform.tfstate). This file maps HCL resource names to real cloud resource IDs.
terraform show # show current state
terraform state list # list all resources in state
terraform state show aws_instance.web # details for one resource
terraform state rm aws_instance.web # remove from state (without destroying)
terraform import aws_instance.web i-0abc123 # import existing resource
terraform output # show output values
terraform output instance_public_ip
Critical: Never store the state file in a plain Git repository — it can contain secrets. Use a remote backend (S3, GCS, Terraform Cloud) with locking.
Modules
Modules are reusable collections of Terraform resources, published on the Terraform Registry.
# Using a community VPC module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}
Workspaces
Workspaces allow multiple state files for the same configuration, useful for dev/staging/prod environments.
terraform workspace new staging # create staging workspace
terraform workspace select staging # switch to it
terraform workspace list # list all workspaces
terraform plan # plan applies to current workspace
Pulumi
Pulumi is a provisioning tool that uses general-purpose programming languages (Python, TypeScript, Go, C#) rather than a DSL. This means infrastructure code can use loops, functions, and libraries naturally.
Python example
pip install pulumi pulumi-aws
pulumi new aws-python # scaffold a new Python project
pulumi preview # dry run
pulumi up # apply
pulumi destroy # tear down
# __main__.py
import pulumi
import pulumi_aws as aws
# Security group
sg = aws.ec2.SecurityGroup("web-sg",
description="Allow HTTP, HTTPS, SSH",
ingress=[
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp", from_port=22, to_port=22,
cidr_blocks=["0.0.0.0/0"],
),
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp", from_port=80, to_port=80,
cidr_blocks=["0.0.0.0/0"],
),
],
egress=[
aws.ec2.SecurityGroupEgressArgs(
protocol="-1", from_port=0, to_port=0,
cidr_blocks=["0.0.0.0/0"],
),
],
)
# EC2 instance
server = aws.ec2.Instance("web-server",
instance_type="t3.micro",
ami="ami-0c55b159cbfafe1f0",
vpc_security_group_ids=[sg.id],
tags={"Name": "web-server"},
)
pulumi.export("public_ip", server.public_ip)
Pulumi state can be stored in Pulumi Cloud (managed) or self-hosted (S3, GCS, Azure Blob).
Ansible
Ansible is an agentless configuration management tool. It connects to target hosts over SSH, runs modules to perform tasks, and records the result. No daemon runs on the target — only Python is required (installed on almost every Linux system).
This section is an overview. For the depth needed to write your own playbooks from scratch — variable precedence, control flow, roles, secret handling, and the failure modes that bite first — see Ansible: Writing Playbooks From Scratch.
Key concepts
| Concept | Description |
|---|---|
| Inventory | List of hosts and groups to manage |
| Playbook | YAML file defining ordered list of plays |
| Play | Maps a group of hosts to a list of tasks |
| Task | A single call to an Ansible module |
| Module | A built-in unit of work (copy a file, install a package, start a service) |
| Role | Reusable collection of tasks, handlers, templates, and variables |
| Handler | A task triggered by notify: on change (e.g., restart nginx after config change) |
Inventory
# inventory/hosts.ini
[webservers]
web1 ansible_host=10.0.0.1
web2 ansible_host=10.0.0.2
[dbservers]
db1 ansible_host=10.0.0.10
[all:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/id_ed25519
# inventory/hosts.yml (YAML format)
all:
children:
webservers:
hosts:
web1:
ansible_host: 10.0.0.1
web2:
ansible_host: 10.0.0.2
dbservers:
hosts:
db1:
ansible_host: 10.0.0.10
vars:
ansible_user: ubuntu
Ad-hoc commands
# Ping all hosts
ansible all -i inventory/hosts.ini -m ping
# Run a shell command
ansible webservers -i inventory/hosts.ini -m shell -a "uptime"
# Install a package
ansible webservers -i inventory/hosts.ini -m apt \
-a "name=nginx state=present" --become
# Copy a file
ansible webservers -i inventory/hosts.ini -m copy \
-a "src=./nginx.conf dest=/etc/nginx/nginx.conf" --become
# Restart a service
ansible webservers -i inventory/hosts.ini -m service \
-a "name=nginx state=restarted" --become
Playbook
# site.yml
---
- name: Configure web servers
hosts: webservers
become: true # run tasks as root (sudo)
vars:
http_port: 80
app_user: www-data
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Deploy Nginx configuration
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-enabled/app.conf
mode: "0644"
notify: Reload Nginx
- name: Ensure Nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Create application user
ansible.builtin.user:
name: ""
system: true
shell: /usr/sbin/nologin
handlers:
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
ansible-playbook -i inventory/hosts.ini site.yml # run playbook
ansible-playbook -i inventory/hosts.ini site.yml --check # dry run
ansible-playbook -i inventory/hosts.ini site.yml --diff # show file diffs
ansible-playbook -i inventory/hosts.ini site.yml --limit web1 # target one host
ansible-playbook -i inventory/hosts.ini site.yml --tags nginx # run tagged tasks only
Jinja2 templates
Ansible uses Jinja2 for templating configuration files:
{# templates/nginx.conf.j2 #}
server {
listen ;
server_name ;
root /var/www/;
index index.html;
}
Roles
Roles provide a standard directory structure for reusable Ansible content:
roles/nginx/
├── tasks/
│ └── main.yml
├── handlers/
│ └── main.yml
├── templates/
│ └── nginx.conf.j2
├── files/
├── vars/
│ └── main.yml
└── defaults/
└── main.yml # lowest-priority variables
# Use a role in a playbook
- name: Configure web servers
hosts: webservers
become: true
roles:
- nginx
- { role: certbot, when: enable_ssl }
Install community roles from Ansible Galaxy:
ansible-galaxy collection install community.general
ansible-galaxy role install geerlingguy.nginx
ansible-galaxy role list # list installed roles
Ansible Vault
Vault encrypts secrets within playbooks and variable files:
ansible-vault create secrets.yml # create encrypted file
ansible-vault edit secrets.yml # edit encrypted file
ansible-vault encrypt existing.yml # encrypt in place
ansible-vault decrypt secrets.yml # decrypt in place
ansible-vault view secrets.yml # view without decrypting to disk
# Use vault file in a playbook
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass
Worked example — deploy a Python web app
# deploy_app.yml
---
- name: Deploy Python application
hosts: appservers
become: true
vars:
app_dir: /opt/myapp
app_user: appuser
python_version: "3.12"
tasks:
- name: Create application user
ansible.builtin.user:
name: ""
system: true
home: ""
shell: /usr/sbin/nologin
- name: Install Python and git
ansible.builtin.apt:
name:
- "python"
- "python-venv"
- git
state: present
update_cache: true
- name: Clone application repository
ansible.builtin.git:
repo: "https://github.com/myorg/myapp.git"
dest: ""
version: main
force: true
become_user: ""
- name: Install Python dependencies
ansible.builtin.pip:
requirements: "/requirements.txt"
virtualenv: "/.venv"
virtualenv_command: "python -m venv"
- name: Deploy systemd service
ansible.builtin.template:
src: templates/myapp.service.j2
dest: /etc/systemd/system/myapp.service
mode: "0644"
notify: Restart myapp
- name: Enable and start application service
ansible.builtin.systemd:
name: myapp
state: started
enabled: true
daemon_reload: true
handlers:
- name: Restart myapp
ansible.builtin.systemd:
name: myapp
state: restarted
daemon_reload: true
Choosing between tools
| Tool | Provisioning | Config management | Language | Agent required |
|---|---|---|---|---|
| Terraform | Yes (primary) | Limited | HCL | No |
| Pulumi | Yes | Limited | Python, etc. | No |
| Ansible | Limited (cloud modules) | Yes (primary) | YAML/Python | No |
| Chef | No | Yes | Ruby DSL | Yes (chef-client) |
| Puppet | No | Yes | Puppet DSL | Yes (puppet agent) |
A common production pattern: Terraform provisions the VMs, then Ansible configures them. Terraform’s local-exec provisioner or a CI/CD pipeline trigger runs Ansible after infrastructure creation.
# In Terraform: run Ansible after instance creation
resource "null_resource" "configure" {
depends_on = [aws_instance.web]
provisioner "local-exec" {
command = <<-EOT
ansible-playbook -i '${aws_instance.web.public_ip},' \
--private-key ~/.ssh/id_ed25519 \
-u ubuntu site.yml
EOT
}
}
Key takeaways
- IaC replaces console clicks with version-controlled, reviewable text — the repo becomes the source of truth, so drift is detectable and every change has an author, timestamp, and diff.
- The ecosystem splits into provisioning (Terraform, Pulumi — create/destroy resources) and configuration management (Ansible, Chef, Puppet — configure software inside running hosts).
- Terraform is declarative HCL with an
init → validate → plan → applyworkflow; its state file maps config to real resources and can contain secrets — keep it in a locking remote backend, never in plain Git. - Ansible is agentless over SSH: inventory → playbook → plays → tasks/modules, with roles for reuse, Jinja2 for templating, and
ansible-vaultfor secrets in variable files. - The common production pattern is both: Terraform provisions the VMs, Ansible configures them — exactly what this course’s lab does with Proxmox.
References
- Terraform documentation. https://developer.hashicorp.com/terraform/docs
- Terraform Registry — providers and modules. https://registry.terraform.io/
- Pulumi documentation. https://www.pulumi.com/docs/
- Ansible documentation. https://docs.ansible.com/ansible/latest/
- Ansible Galaxy — community roles and collections. https://galaxy.ansible.com/
Related course pages: DevSecOps Fundamentals · CI/CD, Secrets, and GitOps · Proxmox setup
🛠️ Maintenance note: HashiCorp moved Terraform to the BSL license in 2023, prompting the MPL-licensed OpenTofu fork (a drop-in
tofuCLI) — the course’s Proxmox config is OpenTofu-compatible, so verify which CLI the lab expects. Provider version pins (aws ~> 5.0), AMI IDs, and thenull_resource/local-execpattern (now often replaced by dedicated provisioner workflows) age quickly; re-check each term.