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 Caddyforward_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