Compare commits

...

4 commits

Author SHA1 Message Date
9ecc7a54fc tightening up the docs 2026-02-28 21:34:02 -07:00
bad5d480d1 adding enable_caddy 2026-02-28 21:33:52 -07:00
e4fdcdc279 Add landing page, Hetzner labels, and Codeberg link
- Add default landing page (roles/caddy/templates/index.html.j2) deployed
  to empty caddy sites; adapted from YC/coming-soon by Steven Tang (MIT),
  with site domain and powered-by footer linking to codeberg.org/opennomad/linderhof
- Apply hcloud_labels to all Hetzner cloud and DNS resources; default to {}
  in role defaults for stacks without the variable defined
- Fix setup.sh: export stack_name so envsubst substitutes it in config.yml
- Add Codeberg repo link to README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:49:22 -07:00
bd90a7e16f Automate DKIM sync and add Hetzner resource labels
- Add dkim_sync.yml: generates DKIM keys for all mail_domains, writes
  keys to stack config (group_vars/all/dkim.yml), and publishes
  mail._domainkey TXT records via dns.yml — replaces manual vault editing
- Remove dkim_keys from vault.yml.setup (public keys don't need encryption)
- Add hcloud_labels to config.yml.setup and apply to server + SSH key in
  provision role, enabling project-level tagging of Hetzner resources
- Fix setup.sh next steps: add missing bootstrap step, replace manual DKIM
  instructions with dkim_sync.yml
- Update CLAUDE.md and README.md accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:06:24 -07:00
15 changed files with 234 additions and 107 deletions

View file

@ -40,12 +40,14 @@ Note: Inventory and vault password are set via `ANSIBLE_INVENTORY` and `ANSIBLE_
- `provision.yml` - Provision a cloud VM (Hetzner)
- `dns.yml` - Manage DNS zones/records via Hetzner DNS API
- `bootstrap.yml` - First-time server setup (run once as root before site.yml)
- `dkim_sync.yml` - Fetch DKIM keys from mailserver and publish to DNS (run once after first mail deploy)
**Full deployment order** (fresh server):
1. `provision.yml` - create server, auto-writes IP to hosts.yml and config.yml
2. `dns.yml` - create DNS records
3. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root)
4. `site.yml` - deploy all services
5. `dkim_sync.yml` - generate DKIM keys, write to stack config, publish to DNS
**Playbook Execution Order** (via `site.yml`):
1. networks.yml - Pre-create all Docker networks (must run before any service)

124
README.md
View file

@ -2,38 +2,27 @@
> *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
**[codeberg.org/opennomad/linderhof](https://codeberg.org/opennomad/linderhof)**
- 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)
a self-hosting stack based on ansible and docker compose that comes with email, web server, git hosting, matrix, monitoring, web analytics, calendar & contacts, backups, overlay networking, and intrusion prevention — no databases, no external services.
other features include:
- runs on opensource
- no databases / no external services
set `enable_<service>: false` in `config.yml` to disable any service — DNS records, Docker networks, and deployment tasks are all skipped automatically.
| service | toggle | default | powered by |
|---|---|---|---|
| web server | `enable_caddy` | on | [caddy](https://caddyserver.com/) |
| email | `enable_mail` | on | [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver), [rainloop](https://www.rainloop.net/) |
| git hosting | `enable_forgejo` | on | [forgejo](https://forgejo.org/) |
| matrix homeserver | `enable_tuwunel` | on | [tuwunel](https://github.com/matrix-construct/tuwunel) |
| monitoring | `enable_monitoring` | on | [prometheus](https://prometheus.io/), [grafana](https://grafana.com/), [loki](https://github.com/grafana/loki), [alloy](https://github.com/grafana/alloy) |
| web analytics | `enable_goaccess` | on | [goaccess](https://goaccess.io/) |
| calendar & contacts | `enable_radicale` | on | [radicale](https://radicale.org/) |
| backups | `enable_restic` | **off** | [restic](https://github.com/restic/restic) |
| overlay network | `enable_nebula` | on | [nebula](https://github.com/slackhq/nebula) |
| image update alerts | `enable_diun` | on | [diun](https://github.com/crazy-max/diun) |
| intrusion prevention | `enable_fail2ban` | on | [fail2ban](https://github.com/fail2ban/fail2ban) |
> **restic** is off by default — it requires a [Hetzner Storage Box](https://www.hetzner.com/storage/storage-box/) for its backup target. enable it and configure `restic_repository` in `config.yml` once you have one.
## what you need
@ -76,41 +65,27 @@ ansible-galaxy collection install -r requirements.yml
## deploy
### provision a server (Hetzner)
full deployment order for a fresh server:
```bash
ansible-playbook playbooks/provision.yml
ansible-playbook playbooks/provision.yml # create server, writes IP to stack config
ansible-playbook playbooks/dns.yml # create DNS zones and records
ansible-playbook playbooks/site.yml --tags bootstrap # users, SSH hardening, packages, Docker
ansible-playbook playbooks/site.yml # deploy all services
ansible-playbook playbooks/dkim_sync.yml # generate DKIM keys and publish to DNS
```
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`.
**provision** creates the server on Hetzner, 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
**dns** creates all zones and records conditional on your `enable_*` settings — disabled services get no DNS entries.
```bash
ansible-playbook playbooks/dns.yml
```
**bootstrap** connects as `root` (the only user on a fresh server), creates your admin user with passwordless sudo, hardens SSH, and installs base packages including Docker.
creates all DNS zones and records for your domain. records are conditional on your `enable_*` settings — disabled services won't get DNS entries.
**site.yml** deploys all enabled services. subsequent runs are idempotent — safe to re-run to apply config changes.
### bootstrap the server
> **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.
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.
**dkim_sync** generates DKIM keys for all mail domains, writes them to your stack config, and publishes the `mail._domainkey` DNS records. safe to re-run.
## bring your own server
@ -118,7 +93,7 @@ deploys all enabled services. subsequent runs are idempotent — safe to re-run
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
2. ensure your SSH key is authorized for the admin user and they have passwordless sudo — or run `ansible-playbook playbooks/site.yml --tags bootstrap` 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`
@ -155,24 +130,6 @@ stack config lives at `$XDG_CONFIG_HOME/linderhof/<stack>/`:
```
## 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
@ -213,24 +170,13 @@ 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:
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:
```bash
docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/<domain>/mail.pub
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
ansible-playbook playbooks/dkim_sync.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
```
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.
## common operations

View file

@ -17,6 +17,7 @@
# ============================================================
# Services — set to false to disable
# ============================================================
enable_caddy: true
enable_mail: true
enable_forgejo: true
enable_monitoring: true
@ -34,6 +35,12 @@ enable_radicale: true
# ============================================================
domain: $domain
server_name: $server_name
# Labels applied to all Hetzner cloud resources (server, SSH key).
# DNS resources do not support labels.
hcloud_labels:
managed-by: linderhof
stack: $stack_name
server_ip: $server_ip
admin_user: $admin_user
admin_shell: /bin/zsh

View file

@ -54,9 +54,3 @@ restic_password: "$restic_password"
# fail2ban (optional — IPs/CIDRs to whitelist)
# fail2ban_ignoreip: "your-home-ip/32"
# DKIM public keys — add after first mail deployment:
# docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub
# Format: "v=DKIM1; k=rsa; p=<base64 public key>"
# dkim_keys:
# $domain: "v=DKIM1; k=rsa; p=..."

View file

@ -4,4 +4,5 @@
become: true
roles:
- caddy
- role: caddy
when: enable_caddy | default(true)

72
playbooks/dkim_sync.yml Normal file
View file

@ -0,0 +1,72 @@
---
# Fetch DKIM public keys from the running mailserver and publish them to DNS.
#
# Safe to re-run — only generates keys for domains that don't have one yet.
# Run after the first mail deployment:
# ansible-playbook playbooks/dkim_sync.yml
#
# What it does:
# 1. Generates DKIM keys for any domain missing one (skips existing keys)
# 2. Discovers each domain's key file at runtime via find — the algorithm prefix
# (rsa-2048-..., ed25519-...) may vary across docker-mailserver versions,
# but the *<domain>.public.dns.txt suffix is stable
# 3. Writes $LINDERHOF_DIR/group_vars/all/dkim.yml (plain file — DKIM keys are public)
# 4. Runs dns.yml to create/update mail._domainkey TXT records for all domains
- name: Fetch DKIM keys from mailserver
hosts: all
gather_facts: false
tasks:
- name: Check for existing DKIM key files
# /srv/mail/config is the host-side mount of /tmp/docker-mailserver in the container
command: find /srv/mail/config/rspamd/dkim -name "*{{ item }}.public.dns.txt" -type f
loop: "{{ mail_domains }}"
register: dkim_existing
changed_when: false
failed_when: false
- name: Generate DKIM keys for domains without existing keys
command: docker exec mailserver setup config dkim
when: dkim_existing.results | selectattr('stdout', 'equalto', '') | list | length > 0
changed_when: true
- name: Find DKIM DNS TXT key file for each domain
command: find /srv/mail/config/rspamd/dkim -name "*{{ item }}.public.dns.txt" -type f
loop: "{{ mail_domains }}"
register: dkim_file_paths
changed_when: false
- name: Read DKIM public key for each domain
slurp:
src: "{{ item.stdout | trim }}"
loop: "{{ dkim_file_paths.results }}"
loop_control:
label: "{{ item.item }}"
register: dkim_keys_raw
- name: Build dkim_keys dict
set_fact:
dkim_keys_collected: >-
{{ dkim_keys_collected | default({}) | combine({item.item.item: item.content | b64decode | trim}) }}
loop: "{{ dkim_keys_raw.results }}"
loop_control:
label: "{{ item.item.item }}"
- name: Write dkim.yml to stack config directory
delegate_to: localhost
become: false
# Written to a separate dkim.yml rather than config.yml so this playbook can safely
# overwrite it without touching the hand-edited config. Ansible loads all files under
# group_vars/all/ automatically, so dkim_keys is available to all roles either way.
copy:
content: |
---
# DKIM public keys — written automatically by dkim_sync.yml, do not edit manually
dkim_keys:
{% for domain_name, key in dkim_keys_collected.items() %}
{{ domain_name }}: "{{ key }}"
{% endfor %}
dest: "{{ lookup('env', 'ANSIBLE_INVENTORY') | dirname }}/group_vars/all/dkim.yml"
- import_playbook: dns.yml

View file

@ -4,13 +4,7 @@
# Zone definitions live in $LINDERHOF_DIR/group_vars/all/dns.yml
# (generated from inventory/group_vars/all/dns.yml.setup by setup.sh).
#
# To add DKIM keys after first mail deployment:
# docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/<domain>/mail.pub
# Then add to vault.yml:
# ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
# dkim_keys:
# example.com: "v=DKIM1; k=rsa; p=..."
# And uncomment the mail._domainkey record in dns.yml.
# DKIM records are managed automatically by dkim_sync.yml — do not add manually.
#
# Usage: ansible-playbook playbooks/dns.yml
- name: Manage DNS zones on Hetzner Cloud

View file

@ -38,6 +38,16 @@
mode: "0775" # also allow members of the docker group to write
loop: "{{ caddy_sites }}"
- name: Deploy default landing page for empty sites
template:
src: index.html.j2
dest: "/srv/caddy/sites/{{ item }}/index.html"
owner: root
group: docker
mode: "0644"
force: false # never overwrite real content
loop: "{{ caddy_sites }}"
- name: Install Caddyfile
template:
src: Caddyfile.j2

View file

@ -0,0 +1,86 @@
{#
Adapted from "Coming Soon" by Steven Tang (github.com/YC/coming-soon)
MIT License — https://github.com/YC/coming-soon/blob/master/LICENSE
#}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ item }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f1117;
color: #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
animation: fadein 2s ease forwards;
}
main {
padding: 2rem;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
font-weight: 600;
margin: 0 0 1rem;
letter-spacing: -0.02em;
color: #ffffff;
}
p {
font-size: clamp(1rem, 2.5vw, 1.25rem);
font-weight: 300;
color: #a0a0a0;
margin: 0;
}
.dot {
color: #00b98b;
}
footer {
position: fixed;
bottom: 1.5rem;
width: 100%;
font-size: 0.8rem;
color: #444;
}
footer a {
color: #555;
text-decoration: none;
}
footer a:hover {
color: #00b98b;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<main>
<h1>{{ item }}<span class="dot">.</span></h1>
<p>Something's on its way.</p>
</main>
<footer>
powered by <a href="https://codeberg.org/opennomad/linderhof" target="_blank" rel="noopener">linderhof</a>
</footer>
</body>
</html>

View file

@ -1,2 +1,3 @@
---
dns_zones: []
hcloud_labels: {} # override in config.yml — see config.yml.setup for recommended labels

View file

@ -7,6 +7,7 @@
ttl: 300
records:
- value: "{{ server_ip }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
@ -18,6 +19,7 @@
ttl: 300
records:
- value: "10 {{ mail_hostname }}."
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
@ -29,6 +31,7 @@
ttl: 300
records:
- value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
@ -40,6 +43,7 @@
ttl: 300
records:
- value: "{{ ('v=DMARC1; p=none; rua=mailto:dmarc@' + extra_domain) | hetzner.hcloud.txt_record }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
@ -51,6 +55,7 @@
ttl: 300
records:
- value: "{{ dkim_keys[extra_domain] | hetzner.hcloud.txt_record }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
when: dkim_keys is defined and extra_domain in dkim_keys

View file

@ -3,6 +3,7 @@
hetzner.hcloud.zone:
name: "{{ item.zone }}"
mode: primary
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
loop: "{{ dns_zones }}"
@ -17,6 +18,7 @@
type: "{{ item.1.type }}"
ttl: "{{ item.1.ttl | default(300) }}"
records: "{{ item.1.records }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
loop: "{{ dns_zones | subelements('records') }}"
@ -29,6 +31,7 @@
hetzner.hcloud.zone:
name: "{{ item }}"
mode: primary
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
loop: "{{ mail_domains | difference([domain]) }}"
@ -51,6 +54,7 @@
ttl: 300
records:
- value: "{{ item.value | hetzner.hcloud.txt_record }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
loop: "{{ dkim_keys | default({}) | dict2items }}"

View file

@ -3,3 +3,4 @@ cloud_provider: hetzner
hcloud_server_type: cx23
hcloud_image: ubuntu-24.04
hcloud_location: fsn1
hcloud_labels: {} # override in config.yml — see config.yml.setup for recommended labels

View file

@ -3,6 +3,7 @@
hetzner.hcloud.ssh_key:
name: "{{ admin_user }}"
public_key: "{{ admin_ssh_key }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
@ -14,6 +15,7 @@
location: "{{ hcloud_location }}"
ssh_keys:
- "{{ admin_user }}"
labels: "{{ hcloud_labels }}"
api_token: "{{ hcloud_token }}"
state: present
register: server_result

View file

@ -45,6 +45,8 @@ ok "collections installed"
echo
info "stack setup"
prompt stack_name "Stack name" "home"
export stack_name
LINDERHOF_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof"
STACK_DIR="$LINDERHOF_CONFIG_DIR/$stack_name"
@ -174,7 +176,7 @@ if [[ -f "$CONFIG" ]]; then
warn "config.yml already exists — skipping (not overwriting)"
else
info "writing config.yml..."
envsubst '$admin_user $server_name $server_ip $domain $ssh_key_pub' \
envsubst '$admin_user $server_name $server_ip $domain $ssh_key_pub $stack_name' \
< "$TEMPLATES/config.yml.setup" > "$CONFIG"
ok "config.yml created"
fi
@ -242,6 +244,6 @@ echo " 2. Review $VAULT (ansible-vault edit)"
echo " 3. Review $DNS_CONFIG"
echo " 4. Provision a server: ansible-playbook playbooks/provision.yml"
echo " 5. Update DNS: ansible-playbook playbooks/dns.yml"
echo " 6. Deploy: ansible-playbook playbooks/site.yml"
echo " 7. After mail deploys, retrieve DKIM keys and add to vault.yml:"
echo " docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub"
echo " 6. Bootstrap server: ansible-playbook playbooks/site.yml --tags bootstrap"
echo " 7. Deploy: ansible-playbook playbooks/site.yml"
echo " 8. Sync DKIM keys to DNS: ansible-playbook playbooks/dkim_sync.yml"