Fix fresh-deploy blockers and clean up architecture

- Seed postfix-accounts.cf before mailserver start to satisfy Dovecot's
  requirement for at least one account on first boot
- Add failed_when: false to mail user/alias list tasks (files don't exist
  on first run)
- Add forgejo_runner_version (was undefined); default to 12
- Create /srv/forgejo/data/gitea/conf before deploying app.ini
- Decouple goaccess sync from restic: new enable_goaccess_sync flag with
  its own goaccess_sync_* variables
- Move Docker installation to bootstrap exclusively; rename docker.yml to
  networks.yml (runs docker_network role only)
- Add radicale_password to vault template and setup.sh
- Fix goaccess sync tasks gated on enable_goaccess_sync
- Add upstream bug comment to authorized_key deprecation warning
- Update CLAUDE.md and README.md throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Johnson 2026-02-28 00:51:16 -07:00
parent 75891c3271
commit b38cd94fc8
23 changed files with 400 additions and 307 deletions

357
README.md
View file

@ -20,6 +20,8 @@ a self-hosting stack based on ansible and docker compose that comes with
- [loki](https://github.com/grafana/loki)
- web analytics
- [goaccess](https://goaccess.io/)
- calendar & contacts
- [radicale](https://radicale.org/)
- backups
- [restic](https://github.com/restic/restic)
- overlay network
@ -29,36 +31,34 @@ a self-hosting stack based on ansible and docker compose that comes with
- intrusion prevention
- [fail2ban](https://github.com/fail2ban/fail2ban)
other features include:
- runs on opensource
- no databases / no external services
## what you need
- **a domain name** — from any registrar. you'll point its nameservers at Hetzner DNS, or manage DNS yourself
- **a [Hetzner Cloud](https://console.hetzner.cloud/) account** with an API token (Read & Write) — used to provision the server and manage DNS records
- **local tools:**
- `ansible` and `ansible-galaxy`
- `direnv` (optional but recommended — loads `.envrc` automatically)
- `ssh-keygen`, `openssl`, `envsubst` (standard on most systems)
if you already have a server with SSH access and passwordless sudo, you can skip provisioning and jump straight to [deploy](#deploy).
## setup
### prerequisites
- [direnv](https://direnv.net/) (optional, loads `.envrc` automatically)
- a [Hetzner Cloud](https://console.hetzner.cloud/) account with an API token (Read & Write)
- a [Hetzner Storage Box](https://www.hetzner.com/storage/storage-box/) (for restic backups, optional)
install python dependencies and ansible collections:
```bash
pip install -r requirements.txt
ansible-galaxy collection install -r requirements.yml
```
### quickstart
run the interactive setup wizard:
```bash
./setup.sh
```
the setup script walks you through everything interactively: stack name, SSH key, vault password, server details, domain, and secrets. it writes all generated config outside the repo to `$XDG_CONFIG_HOME/linderhof/<stack>/` and won't overwrite existing files.
it walks you through: stack name, SSH key, admin username, server hostname, domain, Hetzner API token, and generates all secrets. config is written to `$XDG_CONFIG_HOME/linderhof/<stack>/` and won't overwrite existing files.
after setup, activate the stack and review the generated config:
activate the stack and review the generated config:
```bash
direnv allow # reads .stack file — or: export LINDERHOF_STACK=<stack>
@ -67,15 +67,63 @@ vi $LINDERHOF_DIR/group_vars/all/config.yml
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
```
then provision and deploy:
install ansible collections:
```bash
ansible-galaxy collection install -r requirements.yml
```
## deploy
### provision a server (Hetzner)
```bash
ansible-playbook playbooks/provision.yml
```
creates the server, registers your SSH key, and writes the IP to your stack config automatically. default type is `cx23` (2 vCPU, 4 GB); override with `-e hcloud_server_type=cx33`.
### update DNS
```bash
ansible-playbook playbooks/dns.yml
```
creates all DNS zones and records for your domain. records are conditional on your `enable_*` settings — disabled services won't get DNS entries.
### bootstrap the server
first-time setup of the server (users, SSH hardening, packages, Docker):
```bash
ansible-playbook playbooks/bootstrap.yml
```
this connects as `root` (the only user on a fresh server), creates your admin user with passwordless sudo, sets passwords for `root` and the admin user, hardens SSH, and installs base packages.
### deploy services
```bash
ansible-playbook playbooks/site.yml
```
### multiple stacks
deploys all enabled services. subsequent runs are idempotent — safe to re-run to apply config changes.
> **note:** on first deployment, the mail role briefly stops Caddy to acquire a Let's Encrypt certificate for the mail hostname via certbot standalone. Caddy is restarted immediately after. this only happens once — subsequent runs detect the existing certificate and skip it.
## bring your own server
if you already have an Ubuntu server with SSH access:
1. run `./setup.sh` — enter the server's existing hostname and IP when prompted
2. ensure your SSH key is authorized for the admin user and they have passwordless sudo — or run `bootstrap.yml` first if starting from root access
3. skip `provision.yml` and `dns.yml` if you're managing DNS elsewhere
4. run `ansible-playbook playbooks/site.yml`
## multiple stacks
each stack is an independent deployment with its own inventory, vault, and secrets. to create a second stack:
@ -106,9 +154,29 @@ stack config lives at `$XDG_CONFIG_HOME/linderhof/<stack>/`:
overrides.yml # optional: variable overrides
```
### overriding variables
to override any variable without editing `config.yml`, create `overrides.yml` in the stack's `group_vars/all/`. ansible loads all files there automatically, so any key here wins over `config.yml`:
## service toggles
set `enable_<service>: false` in `config.yml` to disable a service. DNS records, Docker networks, and deployment tasks for that service will all be skipped automatically.
| variable | service |
|---|---|
| `enable_mail` | email (docker-mailserver + rainloop) |
| `enable_forgejo` | git hosting |
| `enable_tuwunel` | Matrix homeserver |
| `enable_monitoring` | Prometheus, Grafana, Loki, Alloy |
| `enable_goaccess` | web analytics |
| `enable_goaccess_sync` | rsync analytics reports to a remote host (off by default) |
| `enable_radicale` | CalDAV/CardDAV |
| `enable_restic` | encrypted backups (requires a Hetzner Storage Box — off by default) |
| `enable_nebula` | overlay network |
| `enable_diun` | Docker image update notifications |
| `enable_fail2ban` | intrusion prevention |
## overriding variables
to override any variable without editing `config.yml`, create `overrides.yml` in the stack's `group_vars/all/`. ansible loads all files there automatically:
```bash
vi $LINDERHOF_DIR/group_vars/all/overrides.yml
@ -127,288 +195,139 @@ caddy_sites:
mail_domains:
- example.com
- example2.com
- example3.com
```
## upstream documentation
- [docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/latest/)
- [rainloop](https://www.rainloop.net/docs/configuration/)
- [caddy](https://caddyserver.com/docs/)
- [forgejo](https://forgejo.org/docs/latest/)
- [tuwunel](https://github.com/matrix-construct/tuwunel)
- [alloy (Grafana Alloy)](https://grafana.com/docs/alloy/latest/)
- [grafana](https://grafana.com/docs/grafana/latest/)
- [prometheus](https://prometheus.io/docs/)
- [loki](https://grafana.com/docs/loki/latest/)
- [goaccess](https://goaccess.io/man)
- [restic](https://restic.readthedocs.io/)
- [nebula](https://nebula.defined.net/docs/)
- [diun](https://crazymax.dev/diun/)
- [fail2ban](https://fail2ban.readthedocs.io/)
## secrets
sensitive data like passwords and DKIM keys is stored in `$LINDERHOF_DIR/group_vars/all/vault.yml` and encrypted with ansible-vault. see the [setup](#setup) section for what goes in there.
sensitive data is stored in `$LINDERHOF_DIR/group_vars/all/vault.yml`, encrypted with ansible-vault. generated by `setup.sh` and never committed to the repo.
after first mail deployment, retrieve and add the DKIM public key:
```bash
# view secrets
ansible-vault view $LINDERHOF_DIR/group_vars/all/vault.yml
# edit secrets (decrypts, opens editor, re-encrypts on save)
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
```
## after first mail deployment — DKIM
retrieve the generated DKIM public key and add it to the vault:
```bash
docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/<domain>/mail.pub
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
# add: dkim_keys:
# example.com: "v=DKIM1; k=rsa; p=..."
```
then uncomment the `mail._domainkey` record in `dns.yml` and re-run `ansible-playbook playbooks/dns.yml`.
```bash
# edit secrets (decrypts in place, opens editor, re-encrypts on save)
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
# decrypt for manual editing
ansible-vault decrypt $LINDERHOF_DIR/group_vars/all/vault.yml
# re-encrypt after editing
ansible-vault encrypt $LINDERHOF_DIR/group_vars/all/vault.yml
add under `dkim_keys`:
```yaml
dkim_keys:
example.com: "v=DKIM1; k=rsa; p=..."
```
## provisioning
provision a new cloud VM (currently supports Hetzner):
```bash
# provision with defaults (server_name and cloud_provider from config.yml)
ansible-playbook playbooks/provision.yml
# override server name or type
ansible-playbook playbooks/provision.yml -e server_name=aspen -e hcloud_server_type=cpx21
```
this registers your SSH key, creates the server, waits for SSH, and updates `$LINDERHOF_DIR/hosts.yml` with the new IP. after provisioning, update DNS and run the stack:
then re-run DNS — the `mail._domainkey` record is created automatically:
```bash
ansible-playbook playbooks/dns.yml
ansible-playbook playbooks/site.yml --tags bootstrap
ansible-playbook playbooks/site.yml
```
## ansible playbooks
Run everything:
```bash
ansible-playbook playbooks/site.yml
```
Run playbooks individually for initial setup (in this order):
```bash
# 1. Bootstrap the server (users, packages, ssh, etc.)
ansible-playbook playbooks/bootstrap.yml
# 2. Install docker
ansible-playbook playbooks/docker.yml
# 3. Set up nebula overlay network
ansible-playbook playbooks/nebula.yml
# 4. Set up the web server
ansible-playbook playbooks/caddy.yml
# 5. Set up the mail server
ansible-playbook playbooks/mail.yml
# 6. Set up forgejo (git server)
ansible-playbook playbooks/forgejo.yml
# 7. Set up tuwunel (matrix homeserver)
ansible-playbook playbooks/tuwunel.yml
# 8. Set up monitoring (prometheus, grafana, loki, alloy)
ansible-playbook playbooks/monitoring.yml
# 9. Set up goaccess (web analytics)
ansible-playbook playbooks/goaccess.yml
# 10. Set up diun (docker image update notifier)
ansible-playbook playbooks/diun.yml
# 11. Set up restic backups
ansible-playbook playbooks/restic.yml
# 12. Set up fail2ban
ansible-playbook playbooks/fail2ban.yml
```
Run only specific tags:
```bash
ansible-playbook playbooks/site.yml --tags mail,monitoring
```
## common operations
Services are deployed to `/srv/<service>`. Each has a `compose.yml` and can be managed with docker compose.
services are deployed to `/srv/<service>`. each has a `compose.yml` and can be managed with docker compose.
### running docker compose commands
### docker compose
```bash
# Always cd to the service directory first
cd /srv/mail && docker compose logs -f
cd /srv/caddy && docker compose restart
cd /srv/forgejo && docker compose ps
cd /srv/tuwunel && docker compose up -d
cd /srv/monitoring && docker compose up -d
```
### reloading caddy
```bash
# Reload caddy configuration without downtime
cd /srv/caddy && docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
```
### managing email users
```bash
# List all email accounts
# list accounts
docker exec mailserver setup email list
# Add a new email account
# add account
docker exec mailserver setup email add user@domain.com password
# Delete an email account
# delete account
docker exec mailserver setup email del user@domain.com
# Update password
# update password
docker exec mailserver setup email update user@domain.com newpassword
```
To add users via ansible, add them to `mail_users` in the vault and run:
to manage users via ansible, edit `mail_users` in the vault and run:
```bash
ansible-playbook --tags users playbooks/mail.yml
ansible-playbook playbooks/mail.yml --tags users
```
### managing email aliases
```bash
# List aliases
docker exec mailserver setup alias list
# Add an alias
docker exec mailserver setup alias add alias@domain.com target@domain.com
# Delete an alias
docker exec mailserver setup alias del alias@domain.com
```
### managing forgejo
```bash
# Access the forgejo CLI
docker exec -it forgejo forgejo
# List users
docker exec forgejo forgejo admin user list
# Create a new user
docker exec forgejo forgejo admin user create --username myuser --password mypassword --email user@domain.com
# Reset a user's password
docker exec forgejo forgejo admin user change-password --username myuser --password newpassword
# Delete a user
docker exec forgejo forgejo admin user delete --username myuser
```
### managing tuwunel (matrix)
### monitoring
```bash
# View tuwunel logs
cd /srv/tuwunel && docker compose logs -f
# Restart tuwunel
cd /srv/tuwunel && docker compose restart
# Check federation status
curl https://chat.example.com/_matrix/federation/v1/version
# Check well-known delegation
curl https://example.com/.well-known/matrix/server
curl https://example.com/.well-known/matrix/client
```
### monitoring stack
```bash
# Reload prometheus configuration
# reload prometheus config
docker exec prometheus kill -HUP 1
# Restart alloy to pick up config changes
# restart alloy
cd /srv/monitoring && docker compose restart alloy
# Check prometheus targets
curl -s localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job: .labels.job, health: .health}'
# Check alloy status
curl -s localhost:12345/-/ready
```
### viewing logs
### nebula overlay network
nebula runs directly on the host (not in Docker). certificates live in `/etc/nebula/`.
```bash
cd /srv/mail && docker compose logs -f mailserver
cd /srv/caddy && docker compose logs -f caddy
cd /srv/forgejo && docker compose logs -f forgejo
cd /srv/tuwunel && docker compose logs -f tuwunel
cd /srv/monitoring && docker compose logs -f grafana
cd /srv/monitoring && docker compose logs -f prometheus
cd /srv/monitoring && docker compose logs -f loki
cd /srv/monitoring && docker compose logs -f alloy
```
### managing nebula
Nebula runs directly on the host (not in Docker). The CA key and certificates are stored in `/etc/nebula/`.
```bash
# Sign a client certificate
ssh server
# sign a client certificate
cd /etc/nebula
nebula-cert sign -name "laptop" -ip "192.168.100.2/24"
# Copy laptop.crt, laptop.key, and ca.crt to client device
```
On the client, install Nebula and create a config with `am_lighthouse: false` and a `static_host_map` pointing to the server's public IP:
```yaml
static_host_map:
"192.168.100.1": ["YOUR_SERVER_PUBLIC_IP:4242"]
lighthouse:
am_lighthouse: false
hosts:
- "192.168.100.1"
```
### dns management
DNS records are managed via the Hetzner DNS API:
```bash
ansible-playbook playbooks/dns.yml
# copy laptop.crt, laptop.key, ca.crt to client
```
### backups
```bash
# Check backup status
docker exec restic restic snapshots
# Run a manual backup
docker exec restic restic backup /data
# Restore from backup
docker exec restic restic restore latest --target /restore
```
## upstream documentation
- [docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/latest/)
- [caddy](https://caddyserver.com/docs/)
- [forgejo](https://forgejo.org/docs/latest/)
- [tuwunel](https://github.com/matrix-construct/tuwunel)
- [grafana](https://grafana.com/docs/grafana/latest/) · [prometheus](https://prometheus.io/docs/) · [loki](https://grafana.com/docs/loki/latest/) · [alloy](https://grafana.com/docs/alloy/latest/)
- [goaccess](https://goaccess.io/man)
- [radicale](https://radicale.org/v3.html)
- [restic](https://restic.readthedocs.io/)
- [nebula](https://nebula.defined.net/docs/)
- [diun](https://crazymax.dev/diun/)
- [fail2ban](https://fail2ban.readthedocs.io/)