Infrastructure as Code: Managing Servers with Ansible, Terraform and Docker
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
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/
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:
- Terraform provisions the server (or you buy hardware and install the OS manually for a homelab).
- Ansible configures the server: installs Docker, sets up firewall, configures monitoring agents, deploys SSH keys.
- Docker Compose defines and runs the services: databases, applications, reverse proxy, monitoring.
- Git version controls everything. Changes are reviewed in pull requests.
- 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.