Homelab Automation
A fully automated homelab configuration managed with Ansible and deployed over a Tailscale VPN. Two nodes cover the stack: a VPS hosting Caddy, CrowdSec, and public services like mljr.eu, and a home NUC running Grafana, Prometheus, and compute workloads. Everything from firewall rules to service updates is codified and repeatable.





The whole homelab — Caddy ingress, CrowdSec, Authelia, Grafana/Loki/VictoriaMetrics, and every self-hosted service this portfolio runs alongside — is described as Ansible roles rather than clicked together by hand. Two hosts cover the stack: a public VPS (mljr) terminating TLS and fronting everything through Caddy, and a home NUC running heavier workloads and the monitoring stack, joined over a Tailscale mesh so neither host needs an open inbound port beyond Caddy's 443. Services are entries in one YAML catalog: name, domain, port, which host runs it, and whether Caddy should sit it behind basic auth or Authelia SSO. A generic role turns that catalog into both a docker-compose deployment and an auto-generated Caddy reverse-proxy snippet — adding a new internet-facing service is a few lines of YAML, not a manual nginx/Caddy edit. Disabling a service in the catalog lets the same automation clean up its stale containers and proxy config instead of leaving orphaned config behind. Deploys run from GitHub Actions over the Tailscale GitHub Action, with all secrets sealed in an Ansible Vault file that's still committed to git (only the ciphertext). A `make deploy-check` dry run verifies vault decryption and host connectivity before anything touches production, and the same playbooks run identically from a laptop for local iteration. The repo also carries reusable service templates — see service-template — for the GitHub Actions workflow-dispatch pattern every deployed service's CI uses to trigger an automated rollout here.
Architecture
graph TD
GHA["GitHub Actions"] -->|Tailscale OAuth| TS[("Tailscale mesh")]
Dev["make deploy-* (local)"] -->|Ansible Vault secrets| TS
TS --> VPS["mljr (VPS)
Caddy ingress · CrowdSec · Authelia"]
TS --> NUC["nuc (home)
Grafana · Loki · VictoriaMetrics"]
VPS -->|reverse_proxy per catalog entry| Services1["Public services
*.mljr.eu"]
NUC --> Services2["Heavier workloads
monitoring stack"]
Catalog[("services catalog
all.yml")] -.generates.-> VPS
Catalog -.generates.-> NUCUnder the hood
One Jinja2 template turns a service's catalog entry (name, domain, port, auth type) into its Caddy reverse-proxy block — auth import and upstream picked automatically.
{% if service.enabled | default(true) and service.domain is defined %}
{% set is_local = service.host == inventory_hostname %}
{% set target_host = 'localhost' if is_local else hostvars[service.host]['ansible_host'] %}
{% set target_port = service.port %}
{% set auth_type = service.caddy_auth | default('') %}
{{ service.domain }} {
log {
output file {{ log_path }}/caddy/{{ service.name }}.log {
roll_size {{ caddy_log_roll_size | default('100mb') }}
}
}
{% if auth_type == 'basicauth' %}
import basicauth_block
{% elif auth_type == 'authelia' %}
import authelia_auth
{% endif %}
reverse_proxy {{ target_host }}:{{ target_port }}
}
{% endif %}