Homelab Automation
A fully automated homelab managed with OpenVox, the open-source Puppet fork. Each machine runs an agent that applies the manifests itself, so there is no central server and no SSH fan-out. A VPS hosts Caddy, CrowdSec and public services like mljr.eu, a home NUC runs Grafana and compute workloads, and the NAS hosts are covered too. 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 OpenVox (Puppet) code rather than clicked together by hand. A public VPS (mljr) terminates TLS and fronts everything through Caddy, a home NUC runs heavier workloads and the monitoring stack, and a NAS host takes backups and light monitoring, all joined over a Tailscale mesh so no host needs an open inbound port beyond Caddy's 443. The project started on Ansible, with one control machine pushing changes to every host over SSH. In 2026 it moved to agent-based OpenVox: each host runs a real Puppet agent and applies its own locally synced copy of the manifests, so there is no puppetserver, no push from a central machine and much less SSH overhead. Appliances that cannot run an agent (an Unraid NAS, a busybox NAS) are managed through proxy-exec blocks declared on the NUC. The Ansible tree was removed after a role-by-role parity audit. Services are entries in one YAML catalog: name, domain, port, which host runs it, and whether Caddy should put it behind basic auth or Authelia SSO. A single defined type turns that catalog into a docker-compose deployment and an auto-generated Caddy reverse-proxy snippet, so a new internet-facing service is a few lines of data. Disabling an entry lets the same code clean up its stale containers and proxy config. Secrets are hiera-eyaml encrypted per host and decrypted on the host at apply time, so CI holds no vault password. GitHub Actions run a weekly deployment and a daily read-only drift check over Tailscale, and "make openvox-check" does the same dry run from a laptop before anything touches production.
Architecture
graph TD GHA["GitHub Actions
weekly deploy · daily drift check"] -->|Tailscale SSH| TS[("Tailscale mesh")] Dev["make openvox-check (local)"] -->|Tailscale SSH| TS TS -->|sync manifests| VPS["mljr (VPS)
agent · Caddy · CrowdSec · Authelia"] TS -->|sync manifests| NUC["nuc (home)
agent · Grafana · Loki · VictoriaMetrics"] TS -->|sync manifests| UG["ugreen (NAS)
agent · backups"] NUC -.proxy-exec.-> NAS["Unraid NAS · WD My Cloud
no agent"] Catalog[("services_catalog
common.yaml")] -.generates.-> VPS Catalog -.generates.-> NUC
Under the hood
One EPP template turns a service's catalog entry (name, domain, port, auth type) into its Caddy reverse-proxy block with logging and rotation.
<%
$name = $service['name']
$domain = $service['domain']
$is_domain_string = $domain =~ String
$target_port = $service['port']
$auth_type = pick_default($service['caddy_auth'], '')
$has_dev_folder = pick_default($service['staging'], false)
-%>
<% if $is_domain_string { -%>
<%= $domain %> {
<% } else { -%>
<%= $domain.join(', ') %> {
<% } -%>
log {
output file <%= $log_path %>/caddy/<%= $name %>.log {
roll_size <%= $roll_size %>
roll_keep <%= $roll_keep %>
roll_keep_for <%= $roll_keep_for %>
<% if $roll_uncompressed { -%>