courses

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:

ℹ️ 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

References


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 tofu CLI) — the course’s Proxmox config is OpenTofu-compatible, so verify which CLI the lab expects. Provider version pins (aws ~> 5.0), AMI IDs, and the null_resource/local-exec pattern (now often replaced by dedicated provisioner workflows) age quickly; re-check each term.