- Shell 72.2%
- Jinja 27%
- Dockerfile 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
hermes setup already wrote its own API_SERVER_KEY to /opt/data/.env, which overrides the container environment at runtime, so the vaulted key was never the one in effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| afa26 | ||
| ansible | ||
| b3 | ||
| butzei_de | ||
| cloud | ||
| factorio | ||
| git | ||
| hermes | ||
| homeassistant | ||
| lebenslicht | ||
| lil-cloud | ||
| lil-freescout | ||
| mastodon | ||
| materialdb | ||
| matrix | ||
| mqtt | ||
| mta-sts | ||
| openclaw | ||
| prometheus | ||
| resilio | ||
| rustdesk | ||
| seq | ||
| tcr | ||
| tcr-webiste | ||
| teamspeak | ||
| teddycloud | ||
| todo | ||
| traefik | ||
| watchtower | ||
| .env.dev.example | ||
| .gitignore | ||
| README.md | ||
docker
Docker Compose stacks for self-hosted services, plus an Ansible playbook that provisions the host they run on (baseline hardening) and deploys the stacks themselves.
Repository layout
.
├── <stack>/docker-compose.yml # one folder per service, e.g. traefik/, matrix/, factorio/...
├── b3/ # git submodule (git.butzei.de/b3/docker-compose) with its own stacks
├── ansible/ # host provisioning + stack deployment (see below)
├── claude.sh # launches Claude Code in a devcontainer for working on this repo
└── .env.dev(.example) # env vars for claude.sh's devcontainer, not for any stack
Each top-level folder is an independent stack: a docker-compose.yml (a couple use docker-compose.yaml) and whatever
config it needs
(custom.ini, prometheus.yml, a Dockerfile, ...). They are not meant to be run from a checkout of this repo
directly on the target host — the Ansible docker_stack role copies each one to the host and runs
docker compose there. See Deploying stacks below.
The b3 folder is a separate git repository, pulled in as a submodule:
git submodule update --init --recursive
Ansible
ansible/ provisions a single Docker host (docker-host in the inventory)
in two phases, both run from site.yml:
baseline— installs Docker Engine, creates the sharedtraefik_defaultnetwork and the/dockerbase directory, and (optionally, off by default) applies a ufw firewall, unattended security upgrades, and SSH hardening.stacks— copies each stack listed instacks.ymlto the host and deploys it withdocker compose.
Requirements
-
Ansible, ansible-core >= 2.19 (
pip install ansibleor your distro's package) — needed for the vaulted deploy key (ssh_agent = autoinansible.cfg) -
The collections in
requirements.yml:cd ansible ansible-galaxy collection install -r requirements.yml
One-time setup
-
Inventory — point
inventory/hosts.ymlat the real host:all: hosts: docker-host: ansible_host: <ip-or-hostname> ansible_user: <ssh-user-with-sudo> -
Vault — if any stack needs a secret (see
stacks.yml), create the vault file from the example, fill it in, and encrypt it:cd ansible/group_vars/all cp vault.yml.example vault.yml $EDITOR vault.yml ansible-vault encrypt vault.yml git diff --staged # confirm it reads as an encrypted blob before committingEdit it later with
ansible-vault edit group_vars/all/vault.yml(decrypts, opens$EDITOR, re-encrypts on save). Both commands prompt for the vault password viavault_password.sh, which is wired up asvault_password_fileinansible.cfg. -
Deploy SSH key — Ansible connects with its own key, not the one in your ssh-agent. The private half is vault-encrypted in
group_vars/all/deploy_key.yml(varvault_ansible_deploy_private_key), the public half isansible_deploy_pubkeyingroup_vars/all/ssh.yml, andbaseline/podman_hostkeep it inansible_user'sauthorized_keys. Each run loads the key into a throwaway ssh-agent that Ansible starts and stops itself, so the key never sits on disk unencrypted. A freshly installed host doesn't have the public key yet: append it to~<ansible_user>/.ssh/authorized_keysby hand once (check the file ends with a newline first). -
Which stacks to deploy —
stacks.ymlstarts empty (stacks: []). Uncomment/add entries for the stacks you actually want Ansible to manage; each needs at minimumnameandpath(relative to the repo root). See the comments in that file forcompose_file,extra_files, andenv(for stacks that need secrets fromvault.yml).
Running it
Always run from the ansible/ directory (or -i/-C won't find the right config). Use ./run.sh instead of
ansible-playbook directly — it's a thin wrapper that forces a working C.UTF-8 locale, working around
ERROR: Ansible could not initialize the preferred locale on controllers with a broken/missing locale setup
(common on freshly installed machines, or ones with mismatched LANG/LC_* env vars). It passes all arguments
straight through to ansible-playbook.
cd ansible
# Dry run first — always
./run.sh site.yml --check --diff
# Baseline only (host hardening, Docker install)
./run.sh site.yml --tags baseline
# Stacks only (deploy whatever's in stacks.yml)
./run.sh site.yml --tags stacks
# Everything
./run.sh site.yml
--check --diff is safe to run at any time — it shows what would change without applying it, and none of the baseline
role's riskier behavior
(ufw enabling, SSH auth changes) is on by default (see the safety notes in group_vars/all/firewall.yml and
group_vars/all/ssh.yml).
Second host: fred (podman)
fred (fred.butzei.lan = 192.168.10.2, openSUSE Tumbleweed) is a second container host that runs rootless
podman as robert, not Docker. It runs autonomous AI agents — rootless, so an escape lands as robert, not root.
It gets its own plays in site.yml:
podman_hostrole (instead ofbaseline, runs with sudo): installspodman+podman-composevia zypper, makes sureroberthas subuid/subgid ranges, enables lingering plus the userpodman-restart.servicesorestart: alwayscontainers survive logout and come back after a reboot, and creates/dockerowned byrobert. The sudo password comes fromansible_become_passwordinhost_vars/fred/vault.yml.- Stacks come from
stacks-fred.yml, notstacks.yml, and are deployed without become — asrobert. The samedocker_stackrole deploys them, buthost_vars/fred/vars.ymlsetscontainer_engine: podman, so it runspodman-compose up -d(with--force-recreateonly when a copied file changed). Podman won't create missing bind-mount sources, so list them under a stack'sdirs:. Userestart: always, fully-qualified image names (docker.io/...) and ports ≥ 1024 in compose files for fred.
./run.sh site.yml --limit fred --check --diff
./run.sh site.yml --limit fred
There is no watchtower on fred. To update an image (as robert, no sudo): cd /docker/<stack> && podman-compose pull && podman-compose up -d --force-recreate.
Notes / gotchas
-
Firewall stays inactive until
firewall_enabled: trueingroup_vars/all/firewall.yml. Rules are staged either way; review the twoFLAGged entries (database ports reachable from the internet) and confirmssh_portmatches the real host before flipping it on. -
SSH hardening is a no-op until you set one of the
ssh_*vars ingroup_vars/all/ssh.yml— confirm key-based login works before settingssh_password_authentication: "no", and verify from a second terminal after applying without closing your current session. -
Unattended upgrades are on by default but automatic reboots are not (
group_vars/all/updates.yml) — a docker host rebooting itself takes every container down at once. -
SSH over 443 (
ws1.butzei.de) —traefik/dynamic/ssh-ws1.ymlroutes TLS-wrapped SSH on port 443 to192.168.10.12:22. SSH has no SNI, sossh -p 443alone will not work; the client has to speak TLS first:Host ws1 HostName ws1.butzei.de User <user> ProxyCommand openssl s_client -quiet -verify_quiet -verify_return_error \ -connect ws1.butzei.de:443 -servername ws1.butzei.deThen
ssh ws1. Traefik terminates TLS and hands plaintext SSH to the box, so the TLS layer is transport camouflage, not authentication — anyone can complete the handshake and reach the login prompt. Keep192.168.10.12key-only (PasswordAuthentication no), or add atcp.middlewares.<name>.ipAllowList.sourceRangeto that file. -
SSH over 443 (
fred.butzei.de) — same setup intraefik/dynamic/ssh-fred.yml, forwarding to192.168.10.2:22(client config is in that file's header). fred should be key-only too — it has a sudo-capable password user and runs AI agents. -
Stack secrets referenced in
stacks.yml'senv:blocks must come fromvault_watchtower_smtp_password-style vars invault.yml— never put a real secret directly instacks.yml, it isn't vault-encrypted. -
Alternatively, a stack can keep its secrets next to its compose file in a vault-encrypted
<stack>/vault.ymlof plain env pairs (API_SERVER_KEY: ..., seehermes/).docker_stackdecrypts it on the control node and writes it to the stack's.envon the host, merged over anyenv:from the stack list. Quote values YAML would reinterpret ("true","0123"), otherwise they land in.envasTrue/123.
Devcontainer (claude.sh)
claude.sh runs Claude Code against this repo inside a container, with the Docker socket, git credentials, and gh
config bind-mounted in from the host:
cp .env.dev.example .env.dev # first time only; fill in values if needed
./claude.sh
It auto-detects DOCKER_GID and the Docker socket path (rootful or rootless) on first run and persists them into
.env.dev, which is gitignored.