Skip to content

Architecture

Four systems carry the whole homelab. Each one has a single job, and they meet at well-defined seams.

Proxmox ──discovers──▶ Ansible ──configures──▶ LXC / VM guests
(pve-dell) │ │
│ │ run
│ ▼
custom_apt_repo Docker Compose stacks
│ │
▼ │ routed by
Nexus ▼
(APT + artifacts) Traefik
╱ ╲
Let's Encrypt Step CA
(public) (internal)
Bitwarden Secrets Manager
└─ injected at container start ─┘

Hosts are never listed by hand. inventory/proxmox.proxmox.yml queries the Proxmox API and returns every VM and LXC, grouped by node and by Proxmox tag. Tagging a guest docker in the Proxmox UI is what puts it in the tag_docker group — the inventory is downstream of Proxmox, so there is nothing to keep in sync.

Three custom roles do the per-host configuration, all idempotent:

RoleResponsibility
step_ca_clientInstalls the step CLI, bootstraps trust with the internal CA, installs the root cert system-wide
custom_apt_repoAdds the Nexus GPG key and sources.list entry so apt install reaches internal packages
bitwardenInstalls the bw and bws CLIs, architecture-aware (x86_64 / aarch64)

Ansible defaults live in ansible/ansible.cfg: remote user unknown224, become off by default, smart fact gathering cached to /tmp/ansible_facts for an hour.

Each directory under docker-compose-apps/ is an independent stack. Larger hosts use compose’s include: directive to assemble one project from per-app files — the UGREEN NAS stack pulls in twelve applications this way, which is why the catalog follows includes when it enumerates services.

Everything needing routing joins the external traefik-web Docker network. Traefik holds both certificate resolvers and decides which applies:

  • Internal services get TLS from the Step CA at stepca.toolsera.lan
  • Public services get Let’s Encrypt certificates via the Cloudflare DNS challenge, reached through a Cloudflare tunnel

Traefik and Portainer never touch the raw Docker socket. They go through docker-socket-proxy, which exposes a read-only slice of the API (CONTAINERS, SERVICES, TASKS=1; POST=0).

No .env file in this repo holds a real secret. Secrets live in Bitwarden Secrets Manager and are injected at the moment a container starts:

  1. The controller exports BWS_ACCESS_TOKEN from ansible/.env
  2. restart-docker-stacks.yml discovers running stacks via docker compose ls
  3. Each stack restarts wrapped in bws run --access-token "$BWS_ACCESS_TOKEN" -- docker compose ... up -d
  4. bws exports every secret in the project as an environment variable; each compose file consumes only the ${VAR} placeholders it references

The practical consequence: a bare docker compose up -d is not equivalent. Variables that exist only in BWS come through empty, which silently starts services with blank credentials. On the traefik host that drops every @plugin-traefik-proxmox-provider router.

Relevant knobs on restart-docker-stacks.yml:

VariableDefaultMeaning
restart_timeout600Per-stack timeout in seconds
bws_inject_secretstrueWrap restarts in bws run; requires the controller token
bws_project_idScope injection to a single BWS project UUID

Custom tooling ships as real Debian packages rather than scripts copied around. Each package carries a package.yml manifest declaring one of two build modes:

  • dpkg — a hand-maintained DEBIAN/control tree assembled with dpkg-deb
  • fpm — assembled entirely from manifest fields (files:, depends:, and so on)

build.sh dispatches on the mode. Releasing is tag-driven: bump version: in the manifest, push a tag shaped pkg/<package>-<version>, and the publish-package.yml workflow builds on the self-hosted homelab,deb-builder runner, uploads to Nexus under bws run, triggers an APT metadata rebuild, and attaches the .deb to a GitHub Release.

The custom_apt_repo role is the other half: it configures hosts to install from that repository. Details in Packages.

LayerStart here
ProvisioningAnsible
InventoryInventory
Running servicesStack catalog
PackagingBuilding & releasing
Live operationsHost access notes