2026-02-27 15:09:25 -07:00
# 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))
2026-02-28 20:49:22 -07:00
**[codeberg.org/opennomad/linderhof ](https://codeberg.org/opennomad/linderhof )**
2026-02-27 15:09:25 -07:00
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/ )
2026-02-28 00:51:16 -07:00
- calendar & contacts
- [radicale ](https://radicale.org/ )
2026-02-27 15:09:25 -07:00
- 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
2026-02-28 00:51:16 -07:00
## what you need
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
- **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)
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
if you already have a server with SSH access and passwordless sudo, you can skip provisioning and jump straight to [deploy ](#deploy ).
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
## setup
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
run the interactive setup wizard:
2026-02-27 15:09:25 -07:00
```bash
./setup.sh
```
2026-02-28 00:51:16 -07:00
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.
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
activate the stack and review the generated config:
2026-02-27 15:09:25 -07:00
```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
```
2026-02-28 00:51:16 -07:00
install ansible collections:
```bash
ansible-galaxy collection install -r requirements.yml
```
## deploy
### provision a server (Hetzner)
2026-02-27 15:09:25 -07:00
```bash
ansible-playbook playbooks/provision.yml
2026-02-28 00:51:16 -07:00
```
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
2026-02-27 15:09:25 -07:00
ansible-playbook playbooks/dns.yml
2026-02-28 00:51:16 -07:00
```
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
2026-02-27 15:09:25 -07:00
ansible-playbook playbooks/site.yml
```
2026-02-28 00:51:16 -07:00
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
2026-02-27 15:09:25 -07:00
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
```
2026-02-28 00:51:16 -07:00
## 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:
2026-02-27 15:09:25 -07:00
```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
2026-02-28 00:51:16 -07:00
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.
2026-02-27 15:09:25 -07:00
```bash
2026-02-28 00:51:16 -07:00
# view secrets
ansible-vault view $LINDERHOF_DIR/group_vars/all/vault.yml
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
# edit secrets (decrypts, opens editor, re-encrypts on save)
2026-02-27 15:09:25 -07:00
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
```
2026-02-28 00:51:16 -07:00
## after first mail deployment — DKIM
2026-02-27 15:09:25 -07:00
2026-02-28 19:06:24 -07:00
run `dkim_sync.yml` once after the first mail deployment — it generates DKIM keys for all mail domains, writes them to your stack config, and publishes the `mail._domainkey` DNS records automatically:
2026-02-27 15:09:25 -07:00
```bash
2026-02-28 19:06:24 -07:00
ansible-playbook playbooks/dkim_sync.yml
2026-02-27 15:09:25 -07:00
```
2026-02-28 19:06:24 -07:00
keys are stored in `$LINDERHOF_DIR/group_vars/all/dkim.yml` (plain file — DKIM public keys are not secret). safe to re-run; only generates keys for domains that don't have one yet.
2026-02-27 15:09:25 -07:00
## common operations
2026-02-28 00:51:16 -07:00
services are deployed to `/srv/<service>` . each has a `compose.yml` and can be managed with docker compose.
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
### docker compose
2026-02-27 15:09:25 -07:00
```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
2026-02-28 00:51:16 -07:00
# list accounts
2026-02-27 15:09:25 -07:00
docker exec mailserver setup email list
2026-02-28 00:51:16 -07:00
# add account
2026-02-27 15:09:25 -07:00
docker exec mailserver setup email add user@domain .com password
2026-02-28 00:51:16 -07:00
# delete account
2026-02-27 15:09:25 -07:00
docker exec mailserver setup email del user@domain .com
2026-02-28 00:51:16 -07:00
# update password
2026-02-27 15:09:25 -07:00
docker exec mailserver setup email update user@domain .com newpassword
```
2026-02-28 00:51:16 -07:00
to manage users via ansible, edit `mail_users` in the vault and run:
2026-02-27 15:09:25 -07:00
```bash
2026-02-28 00:51:16 -07:00
ansible-playbook playbooks/mail.yml --tags users
2026-02-27 15:09:25 -07:00
```
### 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
```
2026-02-28 00:51:16 -07:00
### monitoring
2026-02-27 15:09:25 -07:00
```bash
2026-02-28 00:51:16 -07:00
# reload prometheus config
2026-02-27 15:09:25 -07:00
docker exec prometheus kill -HUP 1
2026-02-28 00:51:16 -07:00
# restart alloy
2026-02-27 15:09:25 -07:00
cd /srv/monitoring & & docker compose restart alloy
```
2026-02-28 00:51:16 -07:00
### nebula overlay network
2026-02-27 15:09:25 -07:00
2026-02-28 00:51:16 -07:00
nebula runs directly on the host (not in Docker). certificates live in `/etc/nebula/` .
2026-02-27 15:09:25 -07:00
```bash
2026-02-28 00:51:16 -07:00
# sign a client certificate
2026-02-27 15:09:25 -07:00
cd /etc/nebula
nebula-cert sign -name "laptop" -ip "192.168.100.2/24"
2026-02-28 00:51:16 -07:00
# copy laptop.crt, laptop.key, ca.crt to client
2026-02-27 15:09:25 -07:00
```
### backups
```bash
docker exec restic restic snapshots
docker exec restic restic backup /data
docker exec restic restic restore latest --target /restore
```
2026-02-28 00:51:16 -07:00
## 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/ )