My homelab was a mess. Here's how I fixed it with code.

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-service module 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:

  1. Each application module outputs a structured definition declaring its name, port, and routing preference:
    // In modules/20-services-apps/n8n/main.tf
    output "service_definition" {
    value = {
    name = "n8n"
    port = 5678
    publish_via = "reverse_proxy" // Needs WebSockets
    }
    }
  2. The root main.tf gathers these definitions from enabled services into one list:
    // In the root main.tf
    locals {
    all_services = flatten([
    for service in module.services : service.service_definition
    ])
    }
  3. Networking modules consume the combined list to configure routes automatically:
    // In the root main.tf
    module "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_via attribute. 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.

High-level architecture of the homelab
Loading diagram...
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_svc

Next 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.