# Incus Implementation Plan ## Goal Build an Incus-based replica of our current Docker Compose infrastructure on a separate git branch (`incus`), managed by Ansible, and test it on a Proxmox VM before considering production use. ## Approach: OCI Containers in Incus Use Incus's native OCI container support (stable since v6.3, July 2024) to run the **exact same Docker images** we use today. The orchestration layer changes from `docker compose` to `incus` CLI commands driven by Ansible. **Why OCI over system containers?** Services like Outline, Dex, and LLDAP are only distributed as Docker images. OCI containers give us the same images with Incus's networking and storage management. No need to install anything from source. **Gitea Runner**: Deferred. It requires Docker socket access — a separate problem that needs its own solution (likely a system container with Docker inside). ## Architecture ### Container Mapping Every service maps 1:1 from Docker to Incus. Container names are preserved so config files (Caddyfile, dex-config.yml) work unchanged. | Container | Image | Volumes | Exposed Ports | |---|---|---|---| | `caddy` | `caddy:2` | caddy-data, caddy-config, Caddyfile mount, /opt/www mount | 80, 443, 443/udp | | `lldap` | `lldap/lldap:stable` | lldap-data | (internal: 17170, 3890) | | `dex` | `dexidp/dex:latest` | dex-data, dex-config mount | (internal: 5556) | | `gitea` | `gitea/gitea:1.21` | gitea-data | 2222→22 | | `gitea-db` | `postgres:16-alpine` | gitea-db-data | (internal: 5432) | | `outline` | `outlinewiki/outline:1.6.0` | outline-data | (internal: 3000) | | `outline-db` | `postgres:16-alpine` | outline-db-data | (internal: 5432) | | `outline-redis` | `redis:7-alpine` | (none) | (internal: 6379) | ### Networking Single managed bridge (`incusbr0`) with dnsmasq providing: - DHCP for all containers - DNS resolution by container name (e.g., `gitea-db` resolves from any container on the bridge) - IPv4 NAT for outbound internet access Port forwarding via Incus proxy devices (replaces Docker's `ports:` directive): ``` caddy: 0.0.0.0:80 → 127.0.0.1:80 0.0.0.0:443 → 127.0.0.1:443 gitea: 0.0.0.0:2222 → 127.0.0.1:22 ``` ### Storage Storage pool `default` with `dir` driver for testing (switch to `zfs` for production). Named volumes created with `incus storage volume create` and attached to containers as disk devices. ### Config File Mounting Config files templated to `/opt/ce-config/` on host, mounted into containers via disk devices: ``` /opt/ce-config/Caddyfile → caddy:/etc/caddy/Caddyfile /opt/ce-config/dex-config.yml → dex:/etc/dex/config.yaml /opt/www/ → caddy:/srv/www ``` ## Ansible Role Structure (on `incus` branch) ``` roles/ common/ ← UNCHANGED from master incus/ ← NEW (replaces roles/docker/) tasks/main.yml ← Install Incus, init preseed, add OCI remote templates/preseed.yml.j2 ← Incus init config services/ ← REWRITTEN for Incus tasks/ main.yml ← Orchestration: config templating, calls service files lldap.yml ← LLDAP container + volume dex.yml ← Dex container + volume + config mount gitea.yml ← Gitea + Gitea-DB + volumes + proxy device outline.yml ← Outline + Outline-DB + Redis + volumes caddy.yml ← Caddy + volumes + config mounts + proxy devices templates/ Caddyfile.j2 ← UNCHANGED (container names match) dex-config.yml.j2 ← UNCHANGED (container names match) ``` ## Per-Service Task Pattern Each service file follows this idempotent pattern: ```yaml # 1. Check if container exists - name: Check if exists command: incus info register: exists failed_when: false changed_when: false # 2. Create storage volume (idempotent — incus ignores if exists) - name: Create volume command: incus storage volume create {{ incus_storage_pool }} when: exists.rc != 0 # 3. Launch OCI container with environment variables - name: Create container command: > incus launch docker:: --config environment.KEY1=value1 --config environment.KEY2=value2 when: exists.rc != 0 # 4. Attach storage volume - name: Attach to command: > incus config device add disk pool={{ incus_storage_pool }} source= path= when: exists.rc != 0 # 5. Attach proxy device (if externally exposed) - name: Add proxy device command: > incus config device add proxy listen=tcp:0.0.0.0: connect=tcp:127.0.0.1: when: exists.rc != 0 # 6. Wait for readiness - name: Wait for command: incus exec -- retries: 10 delay: 3 until: result.rc == 0 ``` ## Implementation Phases ### Phase 0 — Branch + Test Environment 1. `git checkout -b incus` from master 2. Create Debian 13 VM on Proxmox testlab 3. Update `inventory/hosts.yml` to target test VM 4. Verify: `ansible all -m ping` ### Phase 1 — Install Incus (`roles/incus/`) 1. Install `incus` package from Debian 13 repos 2. Initialize with preseed (storage pool + bridge) 3. Add Docker Hub OCI remote 4. Verify: `incus info` succeeds ### Phase 2 — Deploy Services (one at a time) Deploy in dependency order, verifying each before moving on: 1. LLDAP → verify web UI responds 2. Dex → verify OIDC discovery endpoint 3. Gitea-DB → verify `pg_isready` 4. Gitea → verify web UI, add OIDC auth source 5. Outline-DB → verify `pg_isready` 6. Outline-Redis → verify `redis-cli ping` 7. Outline → verify web UI 8. Caddy → verify reverse proxy to all services ### Phase 3 — Playbook Integration - Wire up `bootstrap.yml` (common + incus) - Wire up `deploy.yml` (services) - Full end-to-end test from clean VM ### Phase 4 — Backup/Restore - Rewrite scripts using `incus exec` for pg_dump and `incus storage volume export` for volumes - Test full backup + destroy + restore cycle ### Phase 5 — Documentation - Complete comparison document - Update CLAUDE.md on the incus branch ## Variables to Add ```yaml # Incus configuration (add to inventory/group_vars/all.yml) incus_storage_pool: "default" incus_storage_driver: "dir" # "zfs" for production incus_bridge: "incusbr0" ``` All existing variables (domain, passwords, secrets, image versions) remain unchanged. ## Key Gotchas 1. **Container names must match DNS references** — Caddyfile uses `gitea:3000`, dex-config uses `lldap:3890`. Container names must be exactly `gitea`, `lldap`, etc. 2. **No depends_on** — Ansible task ordering handles startup sequence. Use `retries/delay/until` for readiness checks. 3. **OCI image pull syntax** — `incus launch docker:lldap/lldap:stable lldap` (note: `docker:` prefix) 4. **Config file updates** — Template to host, then `incus restart `. Register changed state in Ansible. 5. **Restart policy** — Set `boot.autostart=true` on all containers. No automatic restart on crash (add watchdog if needed). 6. **UFW still applies** — Host firewall from `common` role still controls which ports are reachable. Current rules (22, 80, 443, 2222) are correct. 7. **First image pull is slow** — Incus downloads and converts OCI images on first launch. Subsequent launches use cache.