The old way of managing servers was logging into each machine via SSH, running commands by hand, and hoping you remembered to do the same thing on every host. It worked for one or two servers. It falls apart completely at five. Infrastructure as Code (IaC) replaces this manual approach with declarative configuration files that describe what your infrastructure should look like, and tools that make it so.

IaC is not just about automation. It is about reproducibility, auditability, and collaboration. When your infrastructure is defined in code, you can version control it, review changes in pull requests, roll back mistakes, and spin up identical environments for testing. This guide covers the three most practical IaC tools for self-hosted infrastructure: Ansible, Terraform, and Docker Compose.

Core IaC Concepts

Declarative vs. Imperative

Imperative means you write step-by-step instructions: "Install nginx, then edit this config file, then restart the service." Declarative means you describe the desired end state: "Nginx should be installed with this configuration and running." The tool figures out what steps are needed to get there.

Characteristic Imperative (scripts) Declarative (IaC)
Approach "Run these commands in order" "Make it look like this"
Idempotency Must be coded manually Built-in
State awareness None (blind execution) Compares current vs. desired
Readability Shows how Shows what
Drift detection Not possible Native feature

Idempotency

An idempotent operation produces the same result whether you run it once or a hundred times. This is critical for IaC. If a playbook installs a package, running it again should not break anything -- it should simply confirm the package is already installed and move on. All good IaC tools enforce idempotency.

Ansible: Configuration Management

Ansible is an agentless configuration management tool. It connects to your servers via SSH and executes tasks defined in YAML files called playbooks. No agent software needs to be installed on managed hosts.

Installation and Inventory

# Install Ansible
sudo apt install -y ansible

# Or via pip for the latest version
pip3 install ansible

# Create an inventory file
# /etc/ansible/hosts or ./inventory.yml
all:
  children:
    docker_hosts:
      hosts:
        server1:
          ansible_host: 192.168.1.101
          ansible_user: admin
        server2:
          ansible_host: 192.168.1.102
          ansible_user: admin
    monitoring:
      hosts:
        monitor1:
          ansible_host: 192.168.1.110
          ansible_user: admin
  vars:
    ansible_python_interpreter: /usr/bin/python3

Your First Playbook

# setup-docker-host.yml
---
- name: Configure Docker hosts
  hosts: docker_hosts
  become: true

  vars:
    docker_compose_version: "2.24.5"
    docker_users:
      - admin

  tasks:
    - name: Update apt cache
      apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install prerequisite packages
      apt:
        name:
          - apt-transport-https
          - ca-certificates
          - curl
          - gnupg
          - lsb-release
          - python3-docker
        state: present

    - name: Add Docker GPG key
      apt_key:
        url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg
        state: present

    - name: Add Docker repository
      apt_repository:
        repo: >-
          deb https://download.docker.com/linux/{{ ansible_distribution | lower }}
          {{ ansible_distribution_release }} stable
        state: present

    - name: Install Docker Engine
      apt:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-compose-plugin
        state: present

    - name: Add users to docker group
      user:
        name: "{{ item }}"
        groups: docker
        append: true
      loop: "{{ docker_users }}"

    - name: Configure Docker daemon
      copy:
        content: |
          {
            "log-driver": "json-file",
            "log-opts": {"max-size": "10m", "max-file": "3"},
            "live-restore": true,
            "storage-driver": "overlay2"
          }
        dest: /etc/docker/daemon.json
      notify: Restart Docker

    - name: Ensure Docker is started and enabled
      systemd:
        name: docker
        state: started
        enabled: true

  handlers:
    - name: Restart Docker
      systemd:
        name: docker
        state: restarted
# Run the playbook
ansible-playbook -i inventory.yml setup-docker-host.yml

# Run in check mode (dry run)
ansible-playbook -i inventory.yml setup-docker-host.yml --check

# Run against a specific host
ansible-playbook -i inventory.yml setup-docker-host.yml --limit server1

Ansible Roles for Reusability

Roles are the standard way to organize Ansible code into reusable components:

# Create a role structure
ansible-galaxy init roles/docker

# Directory structure:
# roles/docker/
#   |- tasks/main.yml
#   |- handlers/main.yml
#   |- templates/daemon.json.j2
#   |- defaults/main.yml
#   |- vars/main.yml
#   |- meta/main.yml

# roles/docker/defaults/main.yml (overridable defaults)
---
docker_edition: "ce"
docker_log_max_size: "10m"
docker_log_max_file: "3"
docker_live_restore: true

# roles/docker/templates/daemon.json.j2
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "{{ docker_log_max_size }}",
    "max-file": "{{ docker_log_max_file }}"
  },
  "live-restore": {{ docker_live_restore | to_json }},
  "storage-driver": "overlay2"
}

# Use the role in a playbook
---
- name: Configure servers
  hosts: docker_hosts
  become: true
  roles:
    - role: docker
      docker_log_max_size: "50m"
    - role: monitoring
    - role: backups

Terraform: Infrastructure Provisioning

While Ansible configures existing servers, Terraform creates infrastructure. It provisions VMs, networks, DNS records, and cloud resources using a declarative configuration language called HCL (HashiCorp Configuration Language).

# main.tf - Provision a Hetzner Cloud server
terraform {
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }
}

provider "hcloud" {
  token = var.hcloud_token
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

# Create an SSH key
resource "hcloud_ssh_key" "default" {
  name       = "homelab-key"
  public_key = file("~/.ssh/id_ed25519.pub")
}

# Create a server
resource "hcloud_server" "docker_host" {
  name        = "docker-01"
  image       = "debian-12"
  server_type = "cx22"
  location    = "nbg1"
  ssh_keys    = [hcloud_ssh_key.default.id]

  user_data = templatefile("cloud-init.yml", {
    ssh_public_key = file("~/.ssh/id_ed25519.pub")
  })
}

# Create a DNS record
resource "cloudflare_record" "docker_host" {
  zone_id = var.cloudflare_zone_id
  name    = "docker-01"
  value   = hcloud_server.docker_host.ipv4_address
  type    = "A"
  proxied = false
}

# Output the IP address
output "server_ip" {
  value = hcloud_server.docker_host.ipv4_address
}
# Initialize Terraform (download providers)
terraform init

# Preview changes
terraform plan

# Apply changes (create infrastructure)
terraform apply

# Destroy infrastructure when done
terraform destroy
Tip: Use Terraform for provisioning (creating servers, networks, DNS) and Ansible for configuration (installing software, deploying services). They complement each other perfectly. Terraform creates the server, then Ansible configures it.

Docker Compose as IaC

Docker Compose files are themselves a form of infrastructure as code. They declare what services should exist, how they connect, what volumes they need, and what environment they run in:

# docker-compose.yml as IaC
services:
  traefik:
    image: traefik:v3.0
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik_certs:/certs
    labels:
      - "traefik.enable=true"
    networks:
      - proxy

  app:
    image: myapp:${APP_VERSION:-latest}
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://user:${DB_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
    networks:
      - proxy
      - backend

  db:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

volumes:
  traefik_certs:
  pgdata:

networks:
  proxy:
    external: true
  backend:

Version Control for Infrastructure

Treat your infrastructure code like application code. Every change goes through version control:

# Repository structure for infrastructure
infrastructure/
  |- ansible/
  |    |- inventory/
  |    |    |- production.yml
  |    |    |- staging.yml
  |    |- playbooks/
  |    |    |- setup-docker.yml
  |    |    |- deploy-monitoring.yml
  |    |- roles/
  |         |- docker/
  |         |- monitoring/
  |         |- backups/
  |- terraform/
  |    |- environments/
  |    |    |- production/
  |    |    |- staging/
  |    |- modules/
  |         |- server/
  |         |- networking/
  |- docker-compose/
  |    |- traefik/
  |    |- monitoring/
  |    |- apps/
  |- .gitignore
  |- README.md

# .gitignore for infrastructure repos
*.tfstate
*.tfstate.*
.terraform/
*.retry
*.pem
*.key
.env
vault-password
secrets/
Warning: Never commit secrets, passwords, API keys, or TLS private keys to version control. Use Ansible Vault, Terraform variables with environment injection, or external secret managers like HashiCorp Vault. If you accidentally commit a secret, rotate it immediately -- removing it from history is not sufficient because it may already be cached.

Secrets Management

# Ansible Vault: encrypt sensitive variables
ansible-vault create group_vars/all/vault.yml
ansible-vault edit group_vars/all/vault.yml

# Content of vault.yml (encrypted at rest):
vault_db_password: "supersecret"
vault_api_key: "sk-1234567890"
vault_ssl_cert_key: |
  -----BEGIN PRIVATE KEY-----
  ...
  -----END PRIVATE KEY-----

# Run playbook with vault password
ansible-playbook deploy.yml --ask-vault-pass
ansible-playbook deploy.yml --vault-password-file ~/.vault_pass

# Terraform: use environment variables
export TF_VAR_db_password="supersecret"
terraform apply

# Or use a .tfvars file (excluded from git)
# terraform.tfvars
db_password = "supersecret"
api_key     = "sk-1234567890"

CI/CD Integration

Automate infrastructure changes through a CI/CD pipeline. Every push to your infrastructure repository triggers validation and optionally deployment:

# .github/workflows/infrastructure.yml (GitHub Actions example)
name: Infrastructure Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Lint Ansible playbooks
        run: |
          pip install ansible-lint
          ansible-lint ansible/playbooks/

      - name: Validate Terraform
        run: |
          cd terraform/environments/production
          terraform init -backend=false
          terraform validate

      - name: Check Compose files
        run: |
          for dir in docker-compose/*/; do
            docker compose -f "$dir/docker-compose.yml" config > /dev/null
          done

  deploy:
    needs: validate
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy with Ansible
        env:
          ANSIBLE_VAULT_PASSWORD: ${{ secrets.VAULT_PASSWORD }}
        run: |
          echo "${{ secrets.SSH_KEY }}" > /tmp/ssh_key
          chmod 600 /tmp/ssh_key
          echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass
          ansible-playbook \
            -i ansible/inventory/production.yml \
            ansible/playbooks/deploy.yml \
            --private-key /tmp/ssh_key \
            --vault-password-file /tmp/vault_pass

Putting It All Together

A complete IaC workflow for a self-hosted infrastructure looks like this:

  1. Terraform provisions the server (or you buy hardware and install the OS manually for a homelab).
  2. Ansible configures the server: installs Docker, sets up firewall, configures monitoring agents, deploys SSH keys.
  3. Docker Compose defines and runs the services: databases, applications, reverse proxy, monitoring.
  4. Git version controls everything. Changes are reviewed in pull requests.
  5. CI/CD validates and deploys changes automatically on merge.

For multi-node Docker environments, tools like usulnet provide a management layer that complements your IaC setup by giving you real-time visibility into the state of containers across all your hosts, with built-in monitoring, backup management, and security scanning.

Start small. You do not need Terraform, Ansible, and CI/CD on day one. Begin with Docker Compose files in a Git repository. Add Ansible when you have more than one server. Add Terraform when you start provisioning cloud resources. Each tool solves a specific problem -- adopt them when you feel the pain they address.