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:
parent
75891c3271
commit
b38cd94fc8
23 changed files with 400 additions and 307 deletions
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
Island is an Ansible-based self-hosting infrastructure stack that deploys email, web server, git hosting, Matrix homeserver, monitoring, and backup services using Docker Compose on Ubuntu servers.
|
||||
Linderhof is an Ansible-based self-hosting infrastructure stack that deploys email, web server, git hosting, Matrix homeserver, monitoring, and backup services using Docker Compose on Ubuntu servers.
|
||||
|
||||
## Common Commands
|
||||
|
||||
|
|
@ -37,23 +37,31 @@ Note: Inventory and vault password are set via `ANSIBLE_INVENTORY` and `ANSIBLE_
|
|||
**Deployment Pattern:** Each service is deployed to `/srv/<service>/` on the target host with a `compose.yml` and environment files.
|
||||
|
||||
**Standalone Playbooks** (not in `site.yml`):
|
||||
- `provision.yml` - Provision a cloud VM (Hetzner). Usage: `ansible-playbook playbooks/provision.yml`
|
||||
- `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)
|
||||
|
||||
**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
|
||||
|
||||
**Playbook Execution Order** (via `site.yml`):
|
||||
1. bootstrap.yml - SSH, sudo, users, base packages (manual only)
|
||||
2. docker.yml - Docker engine installation
|
||||
3. docker_network.yml - Pre-create all Docker networks (must run before any service)
|
||||
4. nebula.yml - Overlay network (Nebula)
|
||||
5. caddy.yml - Web server / reverse proxy
|
||||
6. mail.yml - Email (docker-mailserver + rainloop)
|
||||
7. forgejo.yml - Git server
|
||||
8. tuwunel.yml - Matrix homeserver (Tuwunel)
|
||||
9. monitoring.yml - Prometheus, Grafana, Loki, Alloy
|
||||
10. goaccess.yml - Web analytics
|
||||
11. diun.yml - Docker image update notifications
|
||||
12. restic.yml - Encrypted backups
|
||||
13. fail2ban.yml - Intrusion prevention
|
||||
1. networks.yml - Pre-create all Docker networks (must run before any service)
|
||||
2. nebula.yml - Overlay network (Nebula)
|
||||
3. caddy.yml - Web server / reverse proxy
|
||||
4. mail.yml - Email (docker-mailserver + rainloop)
|
||||
5. forgejo.yml - Git server
|
||||
6. tuwunel.yml - Matrix homeserver (Tuwunel)
|
||||
7. radicale.yml - CalDAV/CardDAV
|
||||
8. monitoring.yml - Prometheus, Grafana, Loki, Alloy
|
||||
9. goaccess.yml - Web analytics
|
||||
10. diun.yml - Docker image update notifications
|
||||
11. restic.yml - Encrypted backups
|
||||
12. fail2ban.yml - Intrusion prevention
|
||||
|
||||
**Mail TLS:** on first deployment, the mail role stops Caddy, runs certbot standalone to acquire a Let's Encrypt cert for `mail_hostname`, then restarts Caddy. subsequent runs skip this (cert already exists). Caddy owns port 80 so standalone is the only viable approach without a DNS challenge plugin.
|
||||
|
||||
**Role Structure:** Each role in `roles/` contains:
|
||||
- `tasks/main.yml` - Core provisioning tasks
|
||||
|
|
@ -90,6 +98,9 @@ caddy_sites:
|
|||
- `enable_forgejo`
|
||||
- `enable_tuwunel`
|
||||
- `enable_monitoring`
|
||||
- `enable_goaccess`
|
||||
- `enable_goaccess_sync`
|
||||
- `enable_radicale`
|
||||
- `enable_restic`
|
||||
- `enable_fail2ban`
|
||||
- `enable_nebula`
|
||||
|
|
|
|||
357
README.md
357
README.md
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@
|
|||
enable_mail: true
|
||||
enable_forgejo: true
|
||||
enable_monitoring: true
|
||||
enable_restic: true
|
||||
enable_restic: false
|
||||
enable_fail2ban: true
|
||||
enable_tuwunel: true
|
||||
enable_nebula: true
|
||||
enable_diun: true
|
||||
enable_goaccess: true
|
||||
enable_goaccess_sync: false
|
||||
enable_radicale: true
|
||||
|
||||
# ============================================================
|
||||
# System
|
||||
|
|
@ -34,6 +36,7 @@ domain: $domain
|
|||
server_name: $server_name
|
||||
server_ip: $server_ip
|
||||
admin_user: $admin_user
|
||||
admin_shell: /bin/zsh
|
||||
admin_ssh_key: "{{ lookup('file', '$ssh_key_pub') }}"
|
||||
timezone: UTC
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ caddy_version: "2"
|
|||
mailserver_version: "latest"
|
||||
rainloop_version: "latest"
|
||||
forgejo_version: "11"
|
||||
forgejo_runner_version: "12"
|
||||
prometheus_version: "latest"
|
||||
alloy_version: "latest"
|
||||
grafana_version: "latest"
|
||||
|
|
@ -143,6 +147,13 @@ goaccess_sites:
|
|||
- rspamd.$domain
|
||||
goaccess_user: admin
|
||||
|
||||
# Sync reports to a remote host via rsync over SSH (enable_goaccess_sync: true to activate)
|
||||
# goaccess_sync_host: "uXXXXXX.your-storagebox.de"
|
||||
# goaccess_sync_user: uXXXXXX
|
||||
# goaccess_sync_ssh_port: 23
|
||||
# goaccess_sync_ssh_key: "/root/.ssh/goaccess_sync"
|
||||
# goaccess_sync_remote_path: "analytics"
|
||||
|
||||
# ============================================================
|
||||
# Diun (Docker Image Update Notifier)
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
#
|
||||
# After first mail deployment, retrieve DKIM keys with:
|
||||
# docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub
|
||||
# Add them to vault.yml and uncomment the mail._domainkey records below.
|
||||
# Then add them to vault.yml under dkim_keys:
|
||||
# dkim_keys:
|
||||
# $domain: "v=DKIM1; k=rsa; p=..."
|
||||
# The mail._domainkey record will be created automatically on next dns.yml run.
|
||||
# ============================================================
|
||||
|
||||
dns_zones:
|
||||
|
|
@ -17,15 +20,17 @@ dns_zones:
|
|||
- name: "@"
|
||||
type: A
|
||||
records:
|
||||
- value: $server_ip
|
||||
- value: "{{ server_ip }}"
|
||||
|
||||
- name: "@"
|
||||
type: MX
|
||||
when: "{{ enable_mail | default(false) }}"
|
||||
records:
|
||||
- value: "10 {{ mail_hostname }}."
|
||||
|
||||
- name: "@"
|
||||
type: TXT
|
||||
when: "{{ enable_mail | default(false) }}"
|
||||
records:
|
||||
- value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}"
|
||||
|
||||
|
|
@ -33,96 +38,66 @@ dns_zones:
|
|||
- name: $server_name
|
||||
type: A
|
||||
records:
|
||||
- value: $server_ip
|
||||
- value: "{{ server_ip }}"
|
||||
|
||||
- name: www
|
||||
type: A
|
||||
records:
|
||||
- value: $server_ip
|
||||
- value: "{{ server_ip }}"
|
||||
|
||||
# Mail subdomain A record (for the mail hostname itself)
|
||||
# Mail subdomain A record
|
||||
- name: "{{ mail_hostname.split('.')[0] }}"
|
||||
type: A
|
||||
when: "{{ enable_mail | default(false) }}"
|
||||
records:
|
||||
- value: $server_ip
|
||||
- value: "{{ server_ip }}"
|
||||
|
||||
# Service CNAMEs
|
||||
- name: webmail
|
||||
type: CNAME
|
||||
when: "{{ enable_mail | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
- name: code
|
||||
type: CNAME
|
||||
when: "{{ enable_forgejo | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
- name: watch
|
||||
type: CNAME
|
||||
when: "{{ enable_monitoring | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
- name: rspamd
|
||||
type: CNAME
|
||||
when: "{{ enable_mail | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
- name: stats
|
||||
type: CNAME
|
||||
when: "{{ enable_goaccess | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
- name: chat
|
||||
type: CNAME
|
||||
when: "{{ enable_tuwunel | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
- name: cal
|
||||
type: CNAME
|
||||
when: "{{ enable_radicale | default(false) }}"
|
||||
records:
|
||||
- value: $server_name.$domain.
|
||||
|
||||
# DMARC
|
||||
- name: _dmarc
|
||||
type: TXT
|
||||
when: "{{ enable_mail | default(false) }}"
|
||||
records:
|
||||
- value: "{{ 'v=DMARC1; p=none; rua=mailto:dmarc@$domain' | hetzner.hcloud.txt_record }}"
|
||||
|
||||
# DKIM — uncomment after first mail deployment and add key to vault.yml
|
||||
# - name: mail._domainkey
|
||||
# type: TXT
|
||||
# records:
|
||||
# - value: "{{ dkim_keys['$domain'] | hetzner.hcloud.txt_record }}"
|
||||
|
||||
# Extra domains (additional mail-hosted domains) — add as needed:
|
||||
# - zone: example2.com
|
||||
# records:
|
||||
# - name: "@"
|
||||
# type: A
|
||||
# records:
|
||||
# - value: $server_ip
|
||||
#
|
||||
# - name: "@"
|
||||
# type: MX
|
||||
# records:
|
||||
# - value: "10 {{ mail_hostname }}."
|
||||
#
|
||||
# - name: "@"
|
||||
# type: TXT
|
||||
# records:
|
||||
# - value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}"
|
||||
#
|
||||
# - name: www
|
||||
# type: CNAME
|
||||
# records:
|
||||
# - value: example2.com.
|
||||
#
|
||||
# - name: _dmarc
|
||||
# type: TXT
|
||||
# records:
|
||||
# - value: "{{ 'v=DMARC1; p=none; rua=mailto:dmarc@example2.com' | hetzner.hcloud.txt_record }}"
|
||||
#
|
||||
# # - name: mail._domainkey
|
||||
# # type: TXT
|
||||
# # records:
|
||||
# # - value: "{{ dkim_keys['example2.com'] | hetzner.hcloud.txt_record }}"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
# Edit with: ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml
|
||||
# ============================================================
|
||||
|
||||
# system
|
||||
root_password: "$root_password"
|
||||
admin_password: "$admin_password"
|
||||
|
||||
# hetzner
|
||||
hcloud_token: "$hcloud_token"
|
||||
|
||||
|
|
@ -33,6 +37,10 @@ grafana_admin_password: "$grafana_admin_password"
|
|||
# token generated with: openssl rand -base64 32
|
||||
tuwunel_registration_token: "$tuwunel_registration_token"
|
||||
|
||||
# radicale
|
||||
# password generated with: openssl rand -base64 32
|
||||
radicale_password: "$radicale_password"
|
||||
|
||||
# goaccess
|
||||
# password generated with: openssl rand -base64 32
|
||||
goaccess_password: "$goaccess_password"
|
||||
|
|
@ -47,9 +55,8 @@ restic_password: "$restic_password"
|
|||
# fail2ban (optional — IPs/CIDRs to whitelist)
|
||||
# fail2ban_ignoreip: "your-home-ip/32"
|
||||
|
||||
# DKIM public keys — one entry per domain
|
||||
# Retrieve after first mail deployment:
|
||||
# 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: ""
|
||||
# dkim_keys:
|
||||
# $domain: "v=DKIM1; k=rsa; p=..."
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
- name: Bootstrap Ubuntu server
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
ansible_user: root
|
||||
|
||||
pre_tasks:
|
||||
- name: Ensure apt cache is up to date
|
||||
|
|
@ -14,16 +16,32 @@
|
|||
timezone:
|
||||
name: "{{ timezone }}"
|
||||
|
||||
- name: Set root password
|
||||
ansible.builtin.user:
|
||||
name: root
|
||||
password: "{{ root_password | password_hash('sha512') }}"
|
||||
|
||||
- name: Create admin user
|
||||
user:
|
||||
ansible.builtin.user:
|
||||
name: "{{ admin_user }}"
|
||||
password: "{{ admin_password | password_hash('sha512') }}"
|
||||
groups: sudo
|
||||
shell: "{{ admin_shell }}"
|
||||
append: true
|
||||
create_home: true
|
||||
|
||||
- name: Grant admin user passwordless sudo
|
||||
lineinfile:
|
||||
path: /etc/sudoers.d/{{ admin_user }}
|
||||
line: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL"
|
||||
create: true
|
||||
mode: "0440"
|
||||
validate: visudo -cf %s
|
||||
|
||||
# BUG: ansible.posix.authorized_key emits a deprecation warning — this is a known upstream bug:
|
||||
# https://github.com/ansible-collections/ansible.posix/issues/695
|
||||
- name: Authorize SSH key for admin user
|
||||
authorized_key:
|
||||
ansible.posix.authorized_key:
|
||||
user: "{{ admin_user }}"
|
||||
key: "{{ admin_ssh_key }}"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
---
|
||||
- name: Install Docker and prepare filesystem
|
||||
- name: Create Docker networks
|
||||
hosts: all
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- docker
|
||||
- docker_network
|
||||
|
|
@ -9,8 +9,8 @@
|
|||
- import_playbook: bootstrap.yml
|
||||
tags: [bootstrap, never] # only runs when explicitly tagged
|
||||
|
||||
- import_playbook: docker.yml
|
||||
tags: [docker]
|
||||
- import_playbook: networks.yml
|
||||
tags: [networks]
|
||||
|
||||
- import_playbook: nebula.yml
|
||||
tags: [nebula]
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
collections:
|
||||
- name: hetzner.hcloud
|
||||
version: ">=6.0.0"
|
||||
- name: ansible.posix
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ www.{{ site }} {
|
|||
|
||||
{% endfor %}
|
||||
{% if enable_mail | default(false) %}
|
||||
http://{{ mail_hostname }} {
|
||||
root * /var/www/acme
|
||||
file_server
|
||||
}
|
||||
|
||||
{{ webmail_domain }} {
|
||||
import access_log
|
||||
reverse_proxy rainloop:{{ rainloop_port }}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ services:
|
|||
- /srv/caddy/config:/config
|
||||
- /srv/caddy/sites:/srv/sites:ro
|
||||
- /srv/goaccess/reports:/srv/goaccess/reports:ro
|
||||
- /var/www/acme:/var/www/acme:ro
|
||||
environment:
|
||||
{% if enable_goaccess | default(true) %}
|
||||
GOACCESS_USER: "{{ goaccess_user }}"
|
||||
|
|
|
|||
56
roles/dns/tasks/extra_mail_domain.yml
Normal file
56
roles/dns/tasks/extra_mail_domain.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
- name: "{{ extra_domain }} A record"
|
||||
hetzner.hcloud.zone_rrset:
|
||||
zone: "{{ extra_domain }}"
|
||||
name: "@"
|
||||
type: A
|
||||
ttl: 300
|
||||
records:
|
||||
- value: "{{ server_ip }}"
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
|
||||
- name: "{{ extra_domain }} MX record"
|
||||
hetzner.hcloud.zone_rrset:
|
||||
zone: "{{ extra_domain }}"
|
||||
name: "@"
|
||||
type: MX
|
||||
ttl: 300
|
||||
records:
|
||||
- value: "10 {{ mail_hostname }}."
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
|
||||
- name: "{{ extra_domain }} SPF record"
|
||||
hetzner.hcloud.zone_rrset:
|
||||
zone: "{{ extra_domain }}"
|
||||
name: "@"
|
||||
type: TXT
|
||||
ttl: 300
|
||||
records:
|
||||
- value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}"
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
|
||||
- name: "{{ extra_domain }} DMARC record"
|
||||
hetzner.hcloud.zone_rrset:
|
||||
zone: "{{ extra_domain }}"
|
||||
name: _dmarc
|
||||
type: TXT
|
||||
ttl: 300
|
||||
records:
|
||||
- value: "{{ ('v=DMARC1; p=none; rua=mailto:dmarc@' + extra_domain) | hetzner.hcloud.txt_record }}"
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
|
||||
- name: "{{ extra_domain }} DKIM record"
|
||||
hetzner.hcloud.zone_rrset:
|
||||
zone: "{{ extra_domain }}"
|
||||
name: mail._domainkey
|
||||
type: TXT
|
||||
ttl: 300
|
||||
records:
|
||||
- value: "{{ dkim_keys[extra_domain] | hetzner.hcloud.txt_record }}"
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
when: dkim_keys is defined and extra_domain in dkim_keys
|
||||
|
|
@ -22,4 +22,39 @@
|
|||
loop: "{{ dns_zones | subelements('records') }}"
|
||||
loop_control:
|
||||
label: "{{ item.0.zone }} {{ item.1.name }} {{ item.1.type }}"
|
||||
when: item.1.when | default(true) | bool
|
||||
tags: dns
|
||||
|
||||
- name: Ensure extra mail domain zones exist
|
||||
hetzner.hcloud.zone:
|
||||
name: "{{ item }}"
|
||||
mode: primary
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
loop: "{{ mail_domains | difference([domain]) }}"
|
||||
when: enable_mail
|
||||
tags: dns
|
||||
|
||||
- name: Configure extra mail domain DNS records
|
||||
ansible.builtin.include_tasks: extra_mail_domain.yml
|
||||
vars:
|
||||
extra_domain: "{{ item }}"
|
||||
loop: "{{ mail_domains | difference([domain]) }}"
|
||||
when: enable_mail
|
||||
tags: dns
|
||||
|
||||
- name: Manage DKIM records
|
||||
hetzner.hcloud.zone_rrset:
|
||||
zone: "{{ item.key }}"
|
||||
name: mail._domainkey
|
||||
type: TXT
|
||||
ttl: 300
|
||||
records:
|
||||
- value: "{{ item.value | hetzner.hcloud.txt_record }}"
|
||||
api_token: "{{ hcloud_token }}"
|
||||
state: present
|
||||
loop: "{{ dkim_keys | default({}) | dict2items }}"
|
||||
loop_control:
|
||||
label: "{{ item.key }} mail._domainkey TXT"
|
||||
when: enable_mail | default(false) and item.value | length > 0
|
||||
tags: dns
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@ forgejo_mailer_enabled: false
|
|||
# forgejo_smtp_password: defined in vault.yml
|
||||
|
||||
# Actions runner
|
||||
forgejo_runner_version: "12"
|
||||
forgejo_runner_name: default-runner
|
||||
forgejo_runner_labels: "docker:docker://node:20-bookworm,ubuntu-latest:docker://ubuntu:latest,ubuntu-22.04:docker://ubuntu:22.04"
|
||||
|
|
|
|||
|
|
@ -13,11 +13,14 @@
|
|||
loop:
|
||||
- /srv/forgejo
|
||||
|
||||
- name: Create Forgejo data directory
|
||||
- name: Create Forgejo data directories
|
||||
ansible.builtin.file:
|
||||
path: /srv/forgejo/data
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
loop:
|
||||
- /srv/forgejo/data
|
||||
- /srv/forgejo/data/gitea/conf
|
||||
|
||||
# stat+chown: avoids UID/GID lookup warnings for container-internal UIDs not present on host
|
||||
- name: Stat Forgejo data directory
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: enable_goaccess_sync | default(false)
|
||||
|
||||
- name: Deploy sync systemd service
|
||||
ansible.builtin.template:
|
||||
|
|
@ -68,6 +69,7 @@
|
|||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload systemd
|
||||
when: enable_goaccess_sync | default(false)
|
||||
|
||||
- name: Deploy sync systemd timer
|
||||
ansible.builtin.template:
|
||||
|
|
@ -77,15 +79,20 @@
|
|||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload systemd
|
||||
when: enable_goaccess_sync | default(false)
|
||||
|
||||
- name: Flush handlers to reload systemd
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Enable and start GoAccess timers
|
||||
- name: Enable and start GoAccess report timer
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
name: goaccess-report.timer
|
||||
enabled: true
|
||||
state: started
|
||||
loop:
|
||||
- goaccess-report.timer
|
||||
- goaccess-sync.timer
|
||||
|
||||
- name: Enable and start GoAccess sync timer
|
||||
ansible.builtin.systemd:
|
||||
name: goaccess-sync.timer
|
||||
enabled: true
|
||||
state: started
|
||||
when: enable_goaccess_sync | default(false)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
set -euo pipefail
|
||||
|
||||
rsync -az --delete \
|
||||
-e "ssh -i {{ restic_ssh_key }} -p {{ restic_ssh_port }} -o StrictHostKeyChecking=no -o BatchMode=yes" \
|
||||
-e "ssh -i {{ goaccess_sync_ssh_key }} -p {{ goaccess_sync_ssh_port }} -o StrictHostKeyChecking=no -o BatchMode=yes" \
|
||||
/srv/goaccess/reports/ \
|
||||
{{ restic_user }}@{{ restic_host }}:analytics/
|
||||
{{ goaccess_sync_user }}@{{ goaccess_sync_host }}:{{ goaccess_sync_remote_path }}/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# read-only docker exec always reports changed; changed_when: false suppresses spurious output
|
||||
# failed_when: false — postfix-virtual.cf may not exist on first run
|
||||
- name: List existing mail aliases
|
||||
command: docker exec mailserver setup alias list
|
||||
register: mail_alias_list
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
tags:
|
||||
- users
|
||||
|
||||
|
|
|
|||
|
|
@ -92,15 +92,32 @@
|
|||
name: certbot
|
||||
state: present
|
||||
|
||||
- name: Check if mail TLS certificate already exists
|
||||
ansible.builtin.stat:
|
||||
path: /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem
|
||||
register: mail_cert
|
||||
|
||||
- name: Stop Caddy to free port 80 for certbot
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /srv/caddy
|
||||
state: stopped
|
||||
when: not mail_cert.stat.exists
|
||||
|
||||
- name: Obtain a Let's Encrypt certificate for {{ mail_hostname }}
|
||||
command: >
|
||||
certbot certonly --standalone
|
||||
-d {{ mail_hostname }}
|
||||
--non-interactive --agree-tos -m postmaster@{{ domain }}
|
||||
args:
|
||||
creates: /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem
|
||||
when: not mail_cert.stat.exists
|
||||
tags: config
|
||||
|
||||
- name: Restart Caddy after certbot
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /srv/caddy
|
||||
state: present
|
||||
build: never
|
||||
when: not mail_cert.stat.exists
|
||||
|
||||
- name: Deploy mail compose file
|
||||
template:
|
||||
src: compose.yml.j2
|
||||
|
|
@ -126,6 +143,17 @@
|
|||
notify: Restart mailserver
|
||||
tags: config
|
||||
|
||||
- name: Seed mail accounts into postfix-accounts.cf before first start
|
||||
ansible.builtin.shell: |
|
||||
grep -qF "{{ item.address }}" /srv/mail/config/postfix-accounts.cf 2>/dev/null && exit 0
|
||||
hash=$(openssl passwd -6 {{ item.password | quote }})
|
||||
printf '%s|{SHA512-CRYPT}%s\n' "{{ item.address }}" "${hash}" >> /srv/mail/config/postfix-accounts.cf
|
||||
loop: "{{ mail_users }}"
|
||||
no_log: true
|
||||
args:
|
||||
executable: /bin/bash
|
||||
tags: users
|
||||
|
||||
- name: Start mailserver
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /srv/mail
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# read-only docker exec always reports changed; changed_when: false suppresses spurious output
|
||||
# failed_when: false — postfix-accounts.cf may not exist on first run (seeded separately)
|
||||
- name: Check if mail user exists
|
||||
command: docker exec mailserver setup email list
|
||||
register: mail_user_list
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
tags:
|
||||
- users
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
---
|
||||
hcloud_server_type: cx22
|
||||
cloud_provider: hetzner
|
||||
hcloud_server_type: cx23
|
||||
hcloud_image: ubuntu-24.04
|
||||
hcloud_location: fsn1
|
||||
|
|
|
|||
|
|
@ -30,11 +30,18 @@
|
|||
|
||||
- name: Update inventory with new IP
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ inventory_dir }}/hosts.yml"
|
||||
path: "{{ lookup('env', 'LINDERHOF_DIR') }}/hosts.yml"
|
||||
regexp: '^\s+ansible_host:'
|
||||
line: " ansible_host: {{ server_ip }}"
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Update config with new IP
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ lookup('env', 'LINDERHOF_DIR') }}/group_vars/all/config.yml"
|
||||
regexp: '^server_ip:'
|
||||
line: "server_ip: {{ server_ip }}"
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Print server IP
|
||||
ansible.builtin.debug:
|
||||
msg: "Server '{{ server_name }}' provisioned at {{ server_ip }}"
|
||||
|
|
|
|||
12
setup.sh
12
setup.sh
|
|
@ -91,7 +91,7 @@ echo
|
|||
info "configure your server"
|
||||
prompt admin_user "Admin username" "$USER"
|
||||
prompt server_name "Server hostname" "$stack_name"
|
||||
prompt server_ip "Server IP (or TBD)" "0.0.0.0"
|
||||
prompt server_ip "Server IP (leave TBD if provisioning via Hetzner)" "TBD"
|
||||
prompt domain "Domain" "example.com"
|
||||
prompt_secret hcloud_token "Hetzner API token (leave blank to skip)"
|
||||
|
||||
|
|
@ -113,17 +113,21 @@ info " rspamd: rspamd.$domain"
|
|||
|
||||
# ── 7. generate secrets ─────────────────────────────────────
|
||||
info "generating secrets..."
|
||||
export root_password admin_password
|
||||
export admin_mail_password notifications_mail_password git_mail_password
|
||||
export grafana_admin_password rspamd_web_password goaccess_password rainloop_admin_password
|
||||
export grafana_admin_password rspamd_web_password goaccess_password rainloop_admin_password radicale_password
|
||||
export tuwunel_registration_token restic_password
|
||||
export forgejo_secret_key forgejo_internal_token forgejo_jwt_secret
|
||||
|
||||
root_password=$(openssl rand -base64 32)
|
||||
admin_password=$(openssl rand -base64 32)
|
||||
admin_mail_password=$(openssl rand -base64 32)
|
||||
notifications_mail_password=$(openssl rand -base64 32)
|
||||
git_mail_password=$(openssl rand -base64 32)
|
||||
grafana_admin_password=$(openssl rand -base64 32)
|
||||
rspamd_web_password=$(openssl rand -base64 32)
|
||||
goaccess_password=$(openssl rand -base64 32)
|
||||
radicale_password=$(openssl rand -base64 32)
|
||||
rainloop_admin_password=$(openssl rand -base64 32)
|
||||
tuwunel_registration_token=$(openssl rand -base64 32)
|
||||
restic_password=$(openssl rand -base64 32)
|
||||
|
|
@ -160,7 +164,7 @@ else
|
|||
info "writing stack.env..."
|
||||
cat > "$STACK_ENV" <<ENV_EOF
|
||||
# Per-stack environment variables — loaded by .envrc
|
||||
export DOCKER_HOST="ssh://$admin_user@$server_name"
|
||||
export DOCKER_HOST="ssh://$admin_user@$server_name.$domain"
|
||||
ENV_EOF
|
||||
ok "stack.env created"
|
||||
fi
|
||||
|
|
@ -181,7 +185,7 @@ if [[ -f "$DNS_CONFIG" ]]; then
|
|||
warn "dns.yml already exists — skipping (not overwriting)"
|
||||
else
|
||||
info "writing dns.yml..."
|
||||
envsubst '$domain $server_ip $server_name' \
|
||||
envsubst '$domain $server_name' \
|
||||
< "$TEMPLATES/dns.yml.setup" > "$DNS_CONFIG"
|
||||
ok "dns.yml created (uncomment DKIM records after first mail deployment)"
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue