On this page
Every homelab starts small. You launch one container to test it. Soon you have a dozen services, scattered docker-compose files, and fragile updates with no central source of truth. That was where my setup landed. I needed a better way to manage the complexity. That’s when I turned to Infrastructure as Code with OpenTofu, the open-source fork of Terraform.
From chaos to code: building a foundation
The goal was to define the homelab in code so the full stack could be destroyed and recreated with one command.
I ended up sorting things into layers:
- Global settings define values that rarely change, like user IDs and the primary domain.
- The networking layer sets up Docker networks and reverse proxies so services communicate internally and route outward.
- Generic services define reusable blueprints, like a
docker-servicemodule that handles standard containers. - Application modules wrap individual services, each consuming the generic blueprint.
The structure looks something like this:
homelab/├── main.tf # The main file that ties everything together├── services/ # Where I enable/disable the services I want to run└── modules/ ├── 00-globals/ ├── 01-networking/ ├── 10-services-generic/ └── 20-services-apps/Exposing services automatically
Adding a new service and exposing it should not require editing reverse proxy configurations by hand. The setup routes definitions through code in three steps:
- Each application module outputs a structured definition declaring its name, port, and routing preference:
// In modules/20-services-apps/n8n/main.tfoutput "service_definition" {value = {name = "n8n"port = 5678publish_via = "reverse_proxy" // Needs WebSockets}}
- The root
main.tfgathers these definitions from enabled services into one list:// In the root main.tflocals {all_services = flatten([for service in module.services : service.service_definition])} - Networking modules consume the combined list to configure routes automatically:
// In the root main.tfmodule "caddy-proxy" {source = "./modules/01-networking/caddy-proxy"services = local.all_services}
This design decouples the services from the publishing mechanism. An application doesn’t need to know how it’s being exposed, it just needs to declare its preference.
Three tiers for publishing
That definition drives three publishing routes:
- Cloudflare Tunnel: Most public services set
publish_via = "tunnel". The module creates an outbound-only connection without open router ports. - Caddy reverse proxy: Applications that need WebSocket support, like AFFiNE, set
publish_via = "reverse_proxy". The Caddy module generates the proxy rules and handles TLS termination. - VPN only: Internal services omit the
publish_viaattribute. They remain accessible only through the local network or a Tailscale connection.
Global settings: .env over .tfvars
I used a root .env file for shared configuration instead of standard terraform.tfvars.
Variables in .tfvars do not flow down to nested modules without passing them explicitly through every layer. Using the germanbrew/dotenv provider, the globals module reads a root .env file instead. Modules that need shared environment defaults, like user IDs and time zones, reference module.system_globals directly.
// In the generic docker-service module, I can now use these globals as defaults default_env_vars = { TZ = module.system_globals.timezone PUID = var.puid != null ? var.puid : module.system_globals.puid PGID = var.pgid != null ? var.pgid : module.system_globals.pgid }Results and maintenance
The initial migration took two weekends. The homelab configuration now lives in git. Recovering from mistakes requires running git revert and tofu apply. If a server fails, cloning the repository onto a fresh machine and running one command restores the environment.
Source
graph TD subgraph Internet direction LR User[User/Client] end
subgraph "Publishing Layer" direction TB cf[Cloudflare Tunnel] caddy[Caddy Reverse Proxy] end
subgraph "Docker Network" n8n[n8n] nocodb[NocoDB] redis[Redis] private_svc[Private Service]
n8n -- uses --> redis nocodb -- uses --> redis end
subgraph "VPN" vpn_user[VPN User] end
User -- HTTPS --> cf User -- HTTPS/WSS --> caddy
cf -- Forwards traffic to --> nocodb caddy -- Forwards traffic to --> n8n
vpn_user -- Tailscale --> private_svcNext steps
The immediate priorities are automated backups and monitoring. Future plans include exploring a multi-node k3s cluster spanning local hardware and a cloud VPS for cross-region redundancy.
The complete OpenTofu configuration is available on GitHub.
Comments
Replies from the fediverse to @yuris@fedi.yuris.dev's post about this article.
No replies yet — open the post above from any Mastodon or fediverse app, reply to it, and your reply will appear here.