No description
  • Shell 72.2%
  • Jinja 27%
  • Dockerfile 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Robert Butzhammer 387427ee47 Drop API_SERVER_KEY from the hermes vault
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>
2026-10-08 18:10:34 +02:00
afa26 Add afa26 stack (nginx serving static site) 2026-08-18 16:43:29 +02:00
ansible Open stack ports in firewalld on podman hosts 2026-10-08 16:26:01 +02:00
b3 Move git to forgero 2026-09-13 12:01:05 +02:00
butzei_de Apply some security fixes after pentest 2026-08-28 12:06:24 +02:00
cloud Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
factorio Wire up todo stack via Ansible and refresh baseline 2026-08-12 18:52:43 +02:00
git Set runner capacity to 3 and remove second runner 2026-09-25 08:55:49 +02:00
hermes Drop API_SERVER_KEY from the hermes vault 2026-10-08 18:10:34 +02:00
homeassistant Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
lebenslicht Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
lil-cloud Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
lil-freescout Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
mastodon Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
materialdb Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
matrix Apply some security fixes after pentest 2026-08-28 12:06:24 +02:00
mqtt Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
mta-sts Apply some security fixes after pentest 2026-08-28 12:06:24 +02:00
openclaw Add openclaw stack (tensorx provider, VPN-only) 2026-08-18 16:40:21 +02:00
prometheus Wire up todo stack via Ansible and refresh baseline 2026-08-12 18:52:43 +02:00
resilio Wire up todo stack via Ansible and refresh baseline 2026-08-12 18:52:43 +02:00
rustdesk Apply some security fixes after pentest 2026-08-28 12:06:24 +02:00
seq Wire up todo stack via Ansible and refresh baseline 2026-08-12 18:52:43 +02:00
tcr Dekete copose files for lil-mattermost, piped, portainer and the - empty - tcr 2026-08-17 10:17:25 +02:00
tcr-webiste Migrate more stacks to ansible 2026-08-14 16:29:13 +02:00
teamspeak Initial 2025-03-31 11:14:06 +02:00
teddycloud Add teddycloud stack 2026-09-01 13:20:34 +02:00
todo Bind the todo app's Gitea feedback config properly 2026-09-02 19:39:58 +02:00
traefik Add Fred 2026-10-04 12:33:44 +02:00
watchtower Wire up todo stack via Ansible and refresh baseline 2026-08-12 18:52:43 +02:00
.env.dev.example chore: add claude.sh dev container for infrastructure editing 2026-07-27 15:00:12 +02:00
.gitignore chore: add claude.sh dev container for infrastructure editing 2026-07-27 15:00:12 +02:00
README.md Document the Ansible deploy key 2026-10-08 16:26:01 +02:00

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:

  1. baseline — installs Docker Engine, creates the shared traefik_default network and the /docker base directory, and (optionally, off by default) applies a ufw firewall, unattended security upgrades, and SSH hardening.
  2. stacks — copies each stack listed in stacks.yml to the host and deploys it with docker compose.

Requirements

  • Ansible, ansible-core >= 2.19 (pip install ansible or your distro's package) — needed for the vaulted deploy key (ssh_agent = auto in ansible.cfg)

  • The collections in requirements.yml:

    cd ansible
    ansible-galaxy collection install -r requirements.yml
    

One-time setup

  1. Inventory — point inventory/hosts.yml at the real host:

    all:
      hosts:
        docker-host:
          ansible_host: <ip-or-hostname>
          ansible_user: <ssh-user-with-sudo>
    
  2. 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 committing
    

    Edit 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 via vault_password.sh, which is wired up as vault_password_file in ansible.cfg.

  3. 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 (var vault_ansible_deploy_private_key), the public half is ansible_deploy_pubkey in group_vars/all/ssh.yml, and baseline / podman_host keep it in ansible_user's authorized_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_keys by hand once (check the file ends with a newline first).

  4. Which stacks to deploy — stacks.yml starts empty (stacks: []). Uncomment/add entries for the stacks you actually want Ansible to manage; each needs at minimum name and path (relative to the repo root). See the comments in that file for compose_file, extra_files, and env (for stacks that need secrets from vault.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_host role (instead of baseline, runs with sudo): installs podman + podman-compose via zypper, makes sure robert has subuid/subgid ranges, enables lingering plus the user podman-restart.service so restart: always containers survive logout and come back after a reboot, and creates /docker owned by robert. The sudo password comes from ansible_become_password in host_vars/fred/vault.yml.
  • Stacks come from stacks-fred.yml, not stacks.yml, and are deployed without become — as robert. The same docker_stack role deploys them, but host_vars/fred/vars.yml sets container_engine: podman, so it runs podman-compose up -d (with --force-recreate only when a copied file changed). Podman won't create missing bind-mount sources, so list them under a stack's dirs:. Use restart: 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: true in group_vars/all/firewall.yml. Rules are staged either way; review the two FLAGged entries (database ports reachable from the internet) and confirm ssh_port matches the real host before flipping it on.

  • SSH hardening is a no-op until you set one of the ssh_* vars in group_vars/all/ssh.yml — confirm key-based login works before setting ssh_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.yml routes TLS-wrapped SSH on port 443 to 192.168.10.12:22. SSH has no SNI, so ssh -p 443 alone 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.de
    

    Then 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. Keep 192.168.10.12 key-only (PasswordAuthentication no), or add a tcp.middlewares.<name>.ipAllowList.sourceRange to that file.

  • SSH over 443 (fred.butzei.de) — same setup in traefik/dynamic/ssh-fred.yml, forwarding to 192.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's env: blocks must come from vault_watchtower_smtp_password-style vars in vault.yml — never put a real secret directly in stacks.yml, it isn't vault-encrypted.

  • Alternatively, a stack can keep its secrets next to its compose file in a vault-encrypted <stack>/vault.yml of plain env pairs (API_SERVER_KEY: ..., see hermes/). docker_stack decrypts it on the control node and writes it to the stack's .env on the host, merged over any env: from the stack list. Quote values YAML would reinterpret ("true", "0123"), otherwise they land in .env as True / 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.