Skip to content

Ansible Configuration Management

Ansible is the backbone of this infrastructure. It handles everything from OS-level hardening (SSH keys, firewall rules, user creation) to deploying complex, multi-container Docker and Kubernetes stacks.

Role-Based Architecture

To maintain a clean and scalable codebase, the configuration is split into distinct Ansible Roles. This modularity allows roles like common or docker to be applied to any new node instantly.

Current Roles include:

  • common: Baseline OS setup, ZeroTier mesh joining, NFS mount to NAS, and SSH hardening.
  • docker: Engine installation, Compose plugin, Python SDK, and user group management.
  • caddy: Centralized reverse proxy with Cloudflare DNS-01 wildcard certificates.
  • authentik: Centralized Identity Provider (IdP) for Single Sign-On.
  • hoodik: Self-hosted cloud storage with automated OIDC injection connecting it to Authentik.
  • homepage: Central dashboard for monitoring infrastructure.
  • firefly: Budget tracking application deployed behind Authentik SSO via Caddy forward_auth.
  • foundryvtt: Custom-built Docker image (Node.js 22-slim) with version-pinned builds, healthcheck, and NAS-mounted user data.

Centralized Service Configuration

All application service variables (subdomains, ports, root paths, version pins) are consolidated in a single services.yml file under group_vars/all/. This eliminates scattered variable definitions and provides a single source of truth for service configuration across all roles.

# =============================================================================
# services.yml — Application Service Definitions
# =============================================================================
# Single source of truth for all Ansible-managed services.
# Drives: Caddyfile templating, Homepage dashboard, Renovate version pins.
#
# Rules:
#   - If a service has a Caddy entry, it belongs here.
#   - If a service has no Caddy entry (proxy_type: no_caddy), it may still live
#     here if other roles consume its data (e.g. teamspeak DNS, port config).
#   - Unmanaged infrastructure endpoints (PVE, PBS, OMV) live in infrastructure.yml.
#   - All image versions must be pinned — no 'latest' tags.
#
# Renovate coverage:
#   - Paired *_image/*_version entries are extracted automatically — no comment needed.
#   - Version-only entries (no matching *_image sibling) need an explicit
#     '# renovate: datasource=... depName=...' comment, or Renovate can't see them.
#   - Per-package versioning overrides (semver/loose/regex) live in renovate.json5's
#     packageRules, not as comment params here.

global_docker_base_path: "/opt"
global_nas_mount_path: "/mnt/NAS_ZFS"
global_timezone: "Europe/Warsaw"

app_services:
  # Identity & Access

  authentik:
    host: "debian-docker"
    subdomain: "auth"
    domain: "potterman.party"
    proxy_type: "standard"
    internal_port: 9000
    root_path: "{{ global_docker_base_path }}/authentik"
    image: "ghcr.io/goauthentik/server"
    version: "2026.5.2"
    # Sidecar versions pinned here for Renovate compatibility.
    ak_postgresql_image: "docker.io/library/postgres"
    ak_postgresql_version: "16-alpine"

    # Core Infrastructure Services

  forgejo:
    host: "debian-docker"
    subdomain: "forgejo"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "codeberg.org/forgejo/forgejo"
    version: "15"
    internal_port: 3000
    ssh_port: 2222
    root_path: "{{ global_docker_base_path }}/forgejo"
    runner_image: "code.forgejo.org/forgejo/runner"
    runner_version: "12.10.2"

  pihole:
    host: "debian-docker"
    subdomain: "pihole"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "pihole/pihole"
    version: "2026.05.0"
    internal_port: 800
    root_path: "{{ global_docker_base_path }}/pihole"

  homepage:
    host: "debian-docker"
    subdomain: "home"
    domain: "potterman.party"
    proxy_type: "standard"
    internal_port: 3005
    root_path: "{{ global_docker_base_path }}/homepage"
    image: "ghcr.io/gethomepage/homepage"
    version: "v1.13.1"

  # Storage & Documents

  paperless:
    host: "debian-docker"
    subdomain: "paperless"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "ghcr.io/paperless-ngx/paperless-ngx"
    version: "2.20.15"
    internal_port: 8000
    root_path: "{{ global_docker_base_path }}/paperless"
    consume_path: "{{ global_nas_mount_path }}/paperless/consume"
    backup_path: "{{ global_nas_mount_path }}/paperless/backup"
    pl_redis_image: "redis"
    pl_redis_version: "7-alpine"
    pl_postgresql_image: "postgres"
    pl_postgresql_version: "16-alpine"
    gotenberg_image: "gotenberg/gotenberg"
    gotenberg_version: "8"
    tika_image: "apache/tika"
    tika_version: "3.3.1.0"

  garage:
    host: "k3s-node1"
    subdomain: "s3"
    domain: "potterman.party"
    proxy_type: "standard"
    internal_port: 30900
    # NodePort service on K3s — no root_path, not Docker-managed.

  # Media

  jellyfin:
    host: "debian-docker"
    subdomain: "jellyfin"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "jellyfin/jellyfin"
    version: "10.11.10"
    internal_port: 8096
    root_path: "{{ global_docker_base_path }}/jellyfin"

  jellyseerr:
    host: "debian-docker"
    subdomain: "jellyseer"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "fallenbagel/jellyseerr"
    version: "2.7.3"
    internal_port: 5055
    root_path: "{{ global_docker_base_path }}/jellyseer"

  sonarr:
    host: "debian-docker"
    subdomain: "sonarr"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "linuxserver/sonarr"
    version: "4.0.17"
    internal_port: 8989
    root_path: "{{ global_docker_base_path }}/sonarr"

  radarr:
    host: "debian-docker"
    subdomain: "radarr"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "linuxserver/radarr"
    version: "6.1.1"
    internal_port: 7878
    root_path: "{{ global_docker_base_path }}/radarr"

  prowlarr:
    host: "debian-docker"
    subdomain: "prowlarr"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "linuxserver/prowlarr"
    version: "2.3.5"
    internal_port: 9696
    root_path: "{{ global_docker_base_path }}/prowlarr"

  sabnzbd:
    host: "debian-docker"
    subdomain: "sabnzbd"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "linuxserver/sabnzbd"
    version: "5.0.3"
    internal_port: 8585
    container_port: 8080
    root_path: "{{ global_docker_base_path }}/sabnzbd"

  decypharr:
    host: "debian-docker"
    subdomain: "decypharr"
    domain: "potterman.party"
    proxy_type: "standard"
    image: "cy01/blackhole"
    version: "v1.1.6"
    internal_port: 8282
    root_path: "{{ global_docker_base_path }}/decypharr"

  # Gaming

  discord_bot:
    root_path: "{{ global_docker_base_path }}/discord-bot"
    python_version: "3.14-slim"
    pycord_version: "2.8.0"        # Renovate-trackable
    aiosqlite_version: "0.22.1"
    aiohttp_version: "3.13.2"

  foundryvtt:
    host: "debian-docker"
    subdomain: "foundry"
    domain: "potterman.party"
    proxy_type: "standard"
    # renovate: datasource=github-releases depName=foundryvtt/foundryvtt-docker
    foundry_version: "14.364"
    internal_port: 30000
    root_path: "{{ global_docker_base_path }}/foundryvtt"
    foundry_user_data_path: "{{ global_nas_mount_path }}/foundryvtt/foundry_user_data"

  pelican:
    image: "ghcr.io/pelican/panel"
    version: "v1.0.0-beta33"
    subdomain: "pelican"
    domain: "potterman.party"
    internal_port: 80
    root_path: "{{ global_docker_base_path }}/pelican"

  # WIP fluxer implementation
  fluxer:
    image_tag: "v1"  # TODO debt: upstream has no pinned release yet, :v1 floats. Track here for Renovate once a real tag exists.
    subdomain: "chat"
    domain: "potterman.party"
    internal_port: 8080
    root_path: "{{ global_docker_base_path }}/fluxer"

  # Monotiring stack (Docker) # TODO: change to Kubernetes

  monitoring:
    prometheus_image: "prom/prometheus"
    prometheus_version: "v3.5.4"
    grafana_image: "grafana/grafana"
    grafana_version: "13.0.2"
    alertmanager_image: prom/alertmanager
    alertmanager_version: "v0.33.0"
    node_exporter_image: "prom/node-exporter"
    node_exporter_version: "v1.11.1"
    blackbox_image: "prom/blackbox-exporter"
    blackbox_version: "v0.28.0"
    root_path: "{{ global_docker_base_path }}/monitoring"

  zsh:
    # renovate: datasource=github-releases depName=starship/starship
    starship_version: "1.22.1"

  common:
    node_exporter_version: "v1.11.1"

Secrets Management

No secrets are stored in plaintext. API Tokens (like Cloudflare) and database passwords are encrypted using Ansible Vault. During the CI/CD pipeline, runners are injected with the Vault password via repository secrets to allow for secure syntax validation.

Master Playbook (site.yml)

The entire cluster state is defined in the master playbook. This file maps the roles to the specific host groups defined in the inventory.

Here is the live site.yml driving the cluster, pulled directly from the repository:

- name: Basic Server Hardening   # Also includes default apps, potterman account and ZeroTier installation
  hosts: all:!workstations
  become: true
  roles:
    - common
    - zsh

- name: Workstation Setup
  hosts: workstations
  roles:
    - zsh

- name: Caddy Reverse Proxy
  hosts: pve
  roles:
    - caddy

- name: Docker Containers
  hosts: docker_nodes
  roles:
    - docker

- name: VPS Services
  hosts: netcup-vps
  roles:
    - homepage

- name: Internal Services
  hosts: debian-docker
  roles:
    - homepage
    - authentik
    - foundryvtt
    - pihole
    - media-stack
    - forgejo
    - paperless
    - pelican
    - discord-bot
    - monitoring

- name: Kubernetes
  hosts: k3s
#  roles:
#    - kubernetes