┌────────────────────────────┐ │ Designing a Two-Tier IaC │ │ Architecture with Ansible │ │ and Incus │ │ 2026-08-15 │ │ │ ├────────────────────────────┤ │ << Back to Blog │ └────────────────────────────┘
╔══════════════════════════════════════╗ ║Designing a Two-Tier IaC Architecture ║ ║ with Ansible and Incus ║ ║ 2026-08-15 ║ ║ ║ ╠══════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════╝
╔══════════════════════════════════════════════════════════╗ ║ Designing a Two-Tier IaC Architecture with Ansible and ║ ║ Incus ║ ║ 2026-08-15 ║ ║ ║ ╠══════════════════════════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════════════════════════════╗ ║ Designing a Two-Tier IaC Architecture with Ansible and Incus ║ ║ 2026-08-15 ║ ║ ║ ╠══════════════════════════════════════════════════════════════════════════════╣ ║ << Back to Blog ║ ╚══════════════════════════════════════════════════════════════════════════════╝
Designing a Two-Tier IaC Architecture with Ansible and Incus
The Problem
Managing distributed bare-metal and virtual infrastructure across geographically dispersed points of presence often tempts engineers into complex clustering solutions like WAN-stretched Kubernetes or Incus clustering across high-latency Internet links.
In our setup—spanning servers across Kansas City (kc), New York (ny), Las Vegas (lv), and Switzerland (ch)—we wanted automated, repeatable deployments for containers, WireGuard meshes, BGP routing nodes, and reverse proxies. But multi-datacenter WAN clusters introduce severe distributed consensus vulnerabilities:
- High latency between nodes slows down Raft/etcd consensus.
- WAN packet loss or transient BGP flapping risks split-brain scenarios or quorum loss.
- A cluster failure at one location can cascade and render independent edge POPs unresponsive.
We needed a clean Infrastructure as Code (IaC) pattern that maintains global automation without fragile network coupling.
The Journey
We evaluated how best to decompose responsibilities between Ansible and Terraform:
Option A: Monolithic Ansible Playbooks
- Use Ansible for both bare-metal OS setup and container management (
community.general.incus). - Drawback: Lacks declarative state tracking for complex container topologies, networking links, and resource constraints; idempotent lifecycle destruction is clunky.
- Use Ansible for both bare-metal OS setup and container management (
Option B: Stretched Incus Cluster over WAN
- Cluster all host machines into a single unified Incus cluster and control it via one Terraform provider.
- Drawback: Incus clusters require reliable sub-millisecond heartbeat latency (dqlite). Running this over transatlantic public links is a recipe for catastrophic database deadlocks.
Option C: Two-Tier Architecture with Standalone Hosts (The Winner)
- Layer 1 (Host Golden-Config): Ansible provisions the base OS (Void Linux / Ubuntu), kernel parameters, sysctl optimizations, WireGuard tunnels, Bird BGP routing, and installs Incus as a standalone daemon on each host.
- Layer 2 (Container Workloads): Terraform manages Incus instances and networks via the
lxc/incusprovider, treating each host as an independent endpoint with its own state stored in MinIO object storage.
The Solution
We codified this decoupled architecture into a clean, phased implementation spec.
1. The Host Layer (Ansible)
Ansible roles apply a golden base configuration to each physical host:
# deploy/roles/incus_host/tasks/main.yml - name: Ensure incus daemon is installed and enabled package: name: incus state: present - name: Configure storage pool on ZFS command: incus admin init --auto --storage-backend=zfs --storage-pool=tank/incus args: creates: /var/lib/incus/storage-pools/tank/incus
2. The Instance Layer (Terraform + Standalone Incus)
Instead of a single brittle cluster, Terraform targets each host independently using remote endpoints:
# main.tf terraform { backend "s3" { bucket = "iac-tfstate" key = "pops/kc.tfstate" endpoint = "https://minio.kc.internal:9000" region = "main" skip_credentials_validation = true skip_metadata_api_check = true force_path_style = true } } provider "incus" { remote { name = "kc" address = "https://10.222.1.1:8443" token = var.incus_kc_token } } resource "incus_instance" "vault" { name = "vault" image = "images:debian/12" ephemeral = false config = { "boot.autostart" = "true" "security.nesting" = "true" } device { name = "eth0" type = "nic" properties = { network = "incusbr0" "ipv4.address" = "10.222.100.10" } } }
3. Secrets and State Management
- Secrets are encrypted using SOPS + age keys, committed directly to git alongside the IaC repositories.
- Remote Terraform state files are isolated per-POP on internal MinIO S3 instances, ensuring that an issue at one POP never blocks deployments at another.
Lessons Learned
- Decoupling Beats Distributed State Over WAN: Avoid multi-region clusters where distributed consensus protocols (Raft, dqlite, etcd) must traverse public Internet hops. Independent hosts with local state are resilient to network partitions.
- Ansible for Substrate, Terraform for Workloads: Let Ansible manage the OS, packages, and kernel configurations. Let Terraform declare and maintain container instances, networks, and storage devices.
- Per-POP State Isolation: Segmenting Terraform state files per location ensures that a maintenance failure or network hiccup in Europe never halts automation pipelines in North America.