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 ─┘Provisioning: Proxmox + Ansible
Section titled “Provisioning: Proxmox + Ansible”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:
| Role | Responsibility |
|---|---|
step_ca_client | Installs the step CLI, bootstraps trust with the internal CA, installs the root cert system-wide |
custom_apt_repo | Adds the Nexus GPG key and sources.list entry so apt install reaches internal packages |
bitwarden | Installs 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.
Runtime: Docker Compose behind Traefik
Section titled “Runtime: Docker Compose behind Traefik”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).
Secrets: injected, never stored
Section titled “Secrets: injected, never stored”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:
- The controller exports
BWS_ACCESS_TOKENfromansible/.env restart-docker-stacks.ymldiscovers running stacks viadocker compose ls- Each stack restarts wrapped in
bws run --access-token "$BWS_ACCESS_TOKEN" -- docker compose ... up -d bwsexports 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:
| Variable | Default | Meaning |
|---|---|---|
restart_timeout | 600 | Per-stack timeout in seconds |
bws_inject_secrets | true | Wrap restarts in bws run; requires the controller token |
bws_project_id | — | Scope injection to a single BWS project UUID |
Distribution: Nexus APT repository
Section titled “Distribution: Nexus APT repository”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-maintainedDEBIAN/controltree assembled withdpkg-debfpm— 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.
Where each piece is documented
Section titled “Where each piece is documented”| Layer | Start here |
|---|---|
| Provisioning | Ansible |
| Inventory | Inventory |
| Running services | Stack catalog |
| Packaging | Building & releasing |
| Live operations | Host access notes |