Compare commits
4 commits
b38cd94fc8
...
9ecc7a54fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ecc7a54fc | |||
| bad5d480d1 | |||
| e4fdcdc279 | |||
| bd90a7e16f |
15 changed files with 234 additions and 107 deletions
|
|
@ -40,12 +40,14 @@ Note: Inventory and vault password are set via `ANSIBLE_INVENTORY` and `ANSIBLE_
|
||||||
- `provision.yml` - Provision a cloud VM (Hetzner)
|
- `provision.yml` - Provision a cloud VM (Hetzner)
|
||||||
- `dns.yml` - Manage DNS zones/records via Hetzner DNS API
|
- `dns.yml` - Manage DNS zones/records via Hetzner DNS API
|
||||||
- `bootstrap.yml` - First-time server setup (run once as root before site.yml)
|
- `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):
|
**Full deployment order** (fresh server):
|
||||||
1. `provision.yml` - create server, auto-writes IP to hosts.yml and config.yml
|
1. `provision.yml` - create server, auto-writes IP to hosts.yml and config.yml
|
||||||
2. `dns.yml` - create DNS records
|
2. `dns.yml` - create DNS records
|
||||||
3. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root)
|
3. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root)
|
||||||
4. `site.yml` - deploy all services
|
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`):
|
**Playbook Execution Order** (via `site.yml`):
|
||||||
1. networks.yml - Pre-create all Docker networks (must run before any service)
|
1. networks.yml - Pre-create all Docker networks (must run before any service)
|
||||||
|
|
|
||||||
124
README.md
124
README.md
|
|
@ -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))
|
> *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
|
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.
|
||||||
- [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:
|
set `enable_<service>: false` in `config.yml` to disable any service — DNS records, Docker networks, and deployment tasks are all skipped automatically.
|
||||||
- runs on opensource
|
|
||||||
- no databases / no external services
|
| 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
|
## what you need
|
||||||
|
|
@ -76,41 +65,27 @@ ansible-galaxy collection install -r requirements.yml
|
||||||
|
|
||||||
## deploy
|
## deploy
|
||||||
|
|
||||||
### provision a server (Hetzner)
|
full deployment order for a fresh server:
|
||||||
|
|
||||||
```bash
|
```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
|
**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.
|
||||||
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.
|
**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):
|
**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.
|
||||||
|
|
||||||
```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
|
## 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:
|
if you already have an Ubuntu server with SSH access:
|
||||||
|
|
||||||
1. run `./setup.sh` — enter the server's existing hostname and IP when prompted
|
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
|
3. skip `provision.yml` and `dns.yml` if you're managing DNS elsewhere
|
||||||
4. run `ansible-playbook playbooks/site.yml`
|
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
|
## overriding variables
|
||||||
|
|
||||||
|
|
@ -213,24 +170,13 @@ ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
|
||||||
|
|
||||||
## after first mail deployment — DKIM
|
## 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
|
```bash
|
||||||
docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/<domain>/mail.pub
|
ansible-playbook playbooks/dkim_sync.yml
|
||||||
ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
add under `dkim_keys`:
|
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.
|
||||||
```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
|
## common operations
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Services — set to false to disable
|
# Services — set to false to disable
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
enable_caddy: true
|
||||||
enable_mail: true
|
enable_mail: true
|
||||||
enable_forgejo: true
|
enable_forgejo: true
|
||||||
enable_monitoring: true
|
enable_monitoring: true
|
||||||
|
|
@ -34,6 +35,12 @@ enable_radicale: true
|
||||||
# ============================================================
|
# ============================================================
|
||||||
domain: $domain
|
domain: $domain
|
||||||
server_name: $server_name
|
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
|
server_ip: $server_ip
|
||||||
admin_user: $admin_user
|
admin_user: $admin_user
|
||||||
admin_shell: /bin/zsh
|
admin_shell: /bin/zsh
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,3 @@ restic_password: "$restic_password"
|
||||||
|
|
||||||
# fail2ban (optional — IPs/CIDRs to whitelist)
|
# fail2ban (optional — IPs/CIDRs to whitelist)
|
||||||
# fail2ban_ignoreip: "your-home-ip/32"
|
# 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=..."
|
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
- caddy
|
- role: caddy
|
||||||
|
when: enable_caddy | default(true)
|
||||||
|
|
|
||||||
72
playbooks/dkim_sync.yml
Normal file
72
playbooks/dkim_sync.yml
Normal 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
|
||||||
|
|
@ -4,13 +4,7 @@
|
||||||
# Zone definitions live in $LINDERHOF_DIR/group_vars/all/dns.yml
|
# Zone definitions live in $LINDERHOF_DIR/group_vars/all/dns.yml
|
||||||
# (generated from inventory/group_vars/all/dns.yml.setup by setup.sh).
|
# (generated from inventory/group_vars/all/dns.yml.setup by setup.sh).
|
||||||
#
|
#
|
||||||
# To add DKIM keys after first mail deployment:
|
# DKIM records are managed automatically by dkim_sync.yml — do not add manually.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Usage: ansible-playbook playbooks/dns.yml
|
# Usage: ansible-playbook playbooks/dns.yml
|
||||||
- name: Manage DNS zones on Hetzner Cloud
|
- name: Manage DNS zones on Hetzner Cloud
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@
|
||||||
mode: "0775" # also allow members of the docker group to write
|
mode: "0775" # also allow members of the docker group to write
|
||||||
loop: "{{ caddy_sites }}"
|
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
|
- name: Install Caddyfile
|
||||||
template:
|
template:
|
||||||
src: Caddyfile.j2
|
src: Caddyfile.j2
|
||||||
|
|
|
||||||
86
roles/caddy/templates/index.html.j2
Normal file
86
roles/caddy/templates/index.html.j2
Normal 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>
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
---
|
---
|
||||||
dns_zones: []
|
dns_zones: []
|
||||||
|
hcloud_labels: {} # override in config.yml — see config.yml.setup for recommended labels
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
ttl: 300
|
ttl: 300
|
||||||
records:
|
records:
|
||||||
- value: "{{ server_ip }}"
|
- value: "{{ server_ip }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
ttl: 300
|
ttl: 300
|
||||||
records:
|
records:
|
||||||
- value: "10 {{ mail_hostname }}."
|
- value: "10 {{ mail_hostname }}."
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
|
|
||||||
|
|
@ -29,6 +31,7 @@
|
||||||
ttl: 300
|
ttl: 300
|
||||||
records:
|
records:
|
||||||
- value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}"
|
- value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
|
|
||||||
|
|
@ -40,6 +43,7 @@
|
||||||
ttl: 300
|
ttl: 300
|
||||||
records:
|
records:
|
||||||
- value: "{{ ('v=DMARC1; p=none; rua=mailto:dmarc@' + extra_domain) | hetzner.hcloud.txt_record }}"
|
- value: "{{ ('v=DMARC1; p=none; rua=mailto:dmarc@' + extra_domain) | hetzner.hcloud.txt_record }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
|
|
||||||
|
|
@ -51,6 +55,7 @@
|
||||||
ttl: 300
|
ttl: 300
|
||||||
records:
|
records:
|
||||||
- value: "{{ dkim_keys[extra_domain] | hetzner.hcloud.txt_record }}"
|
- value: "{{ dkim_keys[extra_domain] | hetzner.hcloud.txt_record }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
when: dkim_keys is defined and extra_domain in dkim_keys
|
when: dkim_keys is defined and extra_domain in dkim_keys
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
hetzner.hcloud.zone:
|
hetzner.hcloud.zone:
|
||||||
name: "{{ item.zone }}"
|
name: "{{ item.zone }}"
|
||||||
mode: primary
|
mode: primary
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
loop: "{{ dns_zones }}"
|
loop: "{{ dns_zones }}"
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
type: "{{ item.1.type }}"
|
type: "{{ item.1.type }}"
|
||||||
ttl: "{{ item.1.ttl | default(300) }}"
|
ttl: "{{ item.1.ttl | default(300) }}"
|
||||||
records: "{{ item.1.records }}"
|
records: "{{ item.1.records }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
loop: "{{ dns_zones | subelements('records') }}"
|
loop: "{{ dns_zones | subelements('records') }}"
|
||||||
|
|
@ -29,6 +31,7 @@
|
||||||
hetzner.hcloud.zone:
|
hetzner.hcloud.zone:
|
||||||
name: "{{ item }}"
|
name: "{{ item }}"
|
||||||
mode: primary
|
mode: primary
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
loop: "{{ mail_domains | difference([domain]) }}"
|
loop: "{{ mail_domains | difference([domain]) }}"
|
||||||
|
|
@ -51,6 +54,7 @@
|
||||||
ttl: 300
|
ttl: 300
|
||||||
records:
|
records:
|
||||||
- value: "{{ item.value | hetzner.hcloud.txt_record }}"
|
- value: "{{ item.value | hetzner.hcloud.txt_record }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
loop: "{{ dkim_keys | default({}) | dict2items }}"
|
loop: "{{ dkim_keys | default({}) | dict2items }}"
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ cloud_provider: hetzner
|
||||||
hcloud_server_type: cx23
|
hcloud_server_type: cx23
|
||||||
hcloud_image: ubuntu-24.04
|
hcloud_image: ubuntu-24.04
|
||||||
hcloud_location: fsn1
|
hcloud_location: fsn1
|
||||||
|
hcloud_labels: {} # override in config.yml — see config.yml.setup for recommended labels
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
hetzner.hcloud.ssh_key:
|
hetzner.hcloud.ssh_key:
|
||||||
name: "{{ admin_user }}"
|
name: "{{ admin_user }}"
|
||||||
public_key: "{{ admin_ssh_key }}"
|
public_key: "{{ admin_ssh_key }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
location: "{{ hcloud_location }}"
|
location: "{{ hcloud_location }}"
|
||||||
ssh_keys:
|
ssh_keys:
|
||||||
- "{{ admin_user }}"
|
- "{{ admin_user }}"
|
||||||
|
labels: "{{ hcloud_labels }}"
|
||||||
api_token: "{{ hcloud_token }}"
|
api_token: "{{ hcloud_token }}"
|
||||||
state: present
|
state: present
|
||||||
register: server_result
|
register: server_result
|
||||||
|
|
|
||||||
10
setup.sh
10
setup.sh
|
|
@ -45,6 +45,8 @@ ok "collections installed"
|
||||||
echo
|
echo
|
||||||
info "stack setup"
|
info "stack setup"
|
||||||
prompt stack_name "Stack name" "home"
|
prompt stack_name "Stack name" "home"
|
||||||
|
export stack_name
|
||||||
|
|
||||||
|
|
||||||
LINDERHOF_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof"
|
LINDERHOF_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof"
|
||||||
STACK_DIR="$LINDERHOF_CONFIG_DIR/$stack_name"
|
STACK_DIR="$LINDERHOF_CONFIG_DIR/$stack_name"
|
||||||
|
|
@ -174,7 +176,7 @@ if [[ -f "$CONFIG" ]]; then
|
||||||
warn "config.yml already exists — skipping (not overwriting)"
|
warn "config.yml already exists — skipping (not overwriting)"
|
||||||
else
|
else
|
||||||
info "writing config.yml..."
|
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"
|
< "$TEMPLATES/config.yml.setup" > "$CONFIG"
|
||||||
ok "config.yml created"
|
ok "config.yml created"
|
||||||
fi
|
fi
|
||||||
|
|
@ -242,6 +244,6 @@ echo " 2. Review $VAULT (ansible-vault edit)"
|
||||||
echo " 3. Review $DNS_CONFIG"
|
echo " 3. Review $DNS_CONFIG"
|
||||||
echo " 4. Provision a server: ansible-playbook playbooks/provision.yml"
|
echo " 4. Provision a server: ansible-playbook playbooks/provision.yml"
|
||||||
echo " 5. Update DNS: ansible-playbook playbooks/dns.yml"
|
echo " 5. Update DNS: ansible-playbook playbooks/dns.yml"
|
||||||
echo " 6. Deploy: ansible-playbook playbooks/site.yml"
|
echo " 6. Bootstrap server: ansible-playbook playbooks/site.yml --tags bootstrap"
|
||||||
echo " 7. After mail deploys, retrieve DKIM keys and add to vault.yml:"
|
echo " 7. Deploy: ansible-playbook playbooks/site.yml"
|
||||||
echo " docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub"
|
echo " 8. Sync DKIM keys to DNS: ansible-playbook playbooks/dkim_sync.yml"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue