- 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>
333 lines
9.8 KiB
Markdown
333 lines
9.8 KiB
Markdown
# Linderhof
|
|
|
|
> *Linderhof* — the smallest and most intimate of Ludwig II's Bavarian palaces, the only one he lived to see completed; built entirely to his own vision as a private retreat. ([Wikipedia](https://en.wikipedia.org/wiki/Linderhof_Palace))
|
|
|
|
a self-hosting stack based on ansible and docker compose that comes with
|
|
|
|
- email
|
|
- [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver)
|
|
- [rainloop](https://www.rainloop.net/)
|
|
- web server
|
|
- [caddy](https://caddyserver.com/)
|
|
- git server
|
|
- [forgejo](https://forgejo.org/)
|
|
- matrix homeserver
|
|
- [tuwunel](https://github.com/matrix-construct/tuwunel)
|
|
- monitoring
|
|
- [alloy](https://github.com/grafana/alloy)
|
|
- [grafana](https://grafana.com/)
|
|
- [prometheus](https://prometheus.io/)
|
|
- [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
|
|
- [nebula](https://github.com/slackhq/nebula)
|
|
- docker image update notifications
|
|
- [diun](https://github.com/crazy-max/diun)
|
|
- 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
|
|
|
|
run the interactive setup wizard:
|
|
|
|
```bash
|
|
./setup.sh
|
|
```
|
|
|
|
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.
|
|
|
|
activate the stack and review the generated config:
|
|
|
|
```bash
|
|
direnv allow # reads .stack file — or: export LINDERHOF_STACK=<stack>
|
|
|
|
vi $LINDERHOF_DIR/group_vars/all/config.yml
|
|
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
|
|
```
|
|
|
|
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
|
|
```
|
|
|
|
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:
|
|
|
|
```bash
|
|
./setup.sh # enter a different stack name when prompted
|
|
echo other-stack > .stack && direnv allow
|
|
```
|
|
|
|
switch between stacks by changing `LINDERHOF_STACK` or updating `.stack`:
|
|
|
|
```bash
|
|
echo home > .stack && direnv allow
|
|
echo work > .stack && direnv allow
|
|
```
|
|
|
|
stack config lives at `$XDG_CONFIG_HOME/linderhof/<stack>/`:
|
|
|
|
```
|
|
<stack>/
|
|
hosts.yml # server connection info
|
|
vault-pass # vault encryption key (chmod 600)
|
|
stack.env # per-stack shell vars (DOCKER_HOST, etc.)
|
|
group_vars/
|
|
all/
|
|
config.yml # public ansible settings
|
|
vault.yml # encrypted secrets
|
|
dns.yml # DNS zone definitions
|
|
overrides.yml # optional: variable overrides
|
|
```
|
|
|
|
|
|
## 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
|
|
```
|
|
|
|
```yaml
|
|
# override mail hostname (e.g. during migration)
|
|
mail_hostname: mail2.example.com
|
|
|
|
# add extra static sites to caddy
|
|
caddy_sites:
|
|
- example.com
|
|
- example2.com
|
|
|
|
# add extra mail-hosted domains
|
|
mail_domains:
|
|
- example.com
|
|
- example2.com
|
|
```
|
|
|
|
|
|
## secrets
|
|
|
|
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.
|
|
|
|
```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 under `dkim_keys`:
|
|
```yaml
|
|
dkim_keys:
|
|
example.com: "v=DKIM1; k=rsa; p=..."
|
|
```
|
|
|
|
then re-run DNS — the `mail._domainkey` record is created automatically:
|
|
|
|
```bash
|
|
ansible-playbook playbooks/dns.yml
|
|
```
|
|
|
|
|
|
## common operations
|
|
|
|
services are deployed to `/srv/<service>`. each has a `compose.yml` and can be managed with docker compose.
|
|
|
|
### docker compose
|
|
|
|
```bash
|
|
cd /srv/mail && docker compose logs -f
|
|
cd /srv/caddy && docker compose restart
|
|
cd /srv/forgejo && docker compose ps
|
|
```
|
|
|
|
### reloading caddy
|
|
|
|
```bash
|
|
cd /srv/caddy && docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
|
```
|
|
|
|
### managing email users
|
|
|
|
```bash
|
|
# list accounts
|
|
docker exec mailserver setup email list
|
|
|
|
# add account
|
|
docker exec mailserver setup email add user@domain.com password
|
|
|
|
# delete account
|
|
docker exec mailserver setup email del user@domain.com
|
|
|
|
# update password
|
|
docker exec mailserver setup email update user@domain.com newpassword
|
|
```
|
|
|
|
to manage users via ansible, edit `mail_users` in the vault and run:
|
|
```bash
|
|
ansible-playbook playbooks/mail.yml --tags users
|
|
```
|
|
|
|
### managing email aliases
|
|
|
|
```bash
|
|
docker exec mailserver setup alias list
|
|
docker exec mailserver setup alias add alias@domain.com target@domain.com
|
|
docker exec mailserver setup alias del alias@domain.com
|
|
```
|
|
|
|
### managing forgejo
|
|
|
|
```bash
|
|
docker exec forgejo forgejo admin user list
|
|
docker exec forgejo forgejo admin user create --username myuser --password mypassword --email user@domain.com
|
|
docker exec forgejo forgejo admin user change-password --username myuser --password newpassword
|
|
```
|
|
|
|
### monitoring
|
|
|
|
```bash
|
|
# reload prometheus config
|
|
docker exec prometheus kill -HUP 1
|
|
|
|
# restart alloy
|
|
cd /srv/monitoring && docker compose restart alloy
|
|
```
|
|
|
|
### nebula overlay network
|
|
|
|
nebula runs directly on the host (not in Docker). certificates live in `/etc/nebula/`.
|
|
|
|
```bash
|
|
# sign a client certificate
|
|
cd /etc/nebula
|
|
nebula-cert sign -name "laptop" -ip "192.168.100.2/24"
|
|
# copy laptop.crt, laptop.key, ca.crt to client
|
|
```
|
|
|
|
### backups
|
|
|
|
```bash
|
|
docker exec restic restic snapshots
|
|
docker exec restic restic backup /data
|
|
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/)
|