--- - name: Provision monotrope.au server hosts: all become: true vars: site_dir: /var/www/monotrope deploy_user: deploy deploy_pubkey: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_ed25519.pub') }}" miniflux_db_password: "{{ lookup('env', 'MINIFLUX_DB_PASSWORD') }}" miniflux_admin_user: "{{ lookup('env', 'MINIFLUX_ADMIN_USER') | default('admin') }}" miniflux_admin_password: "{{ lookup('env', 'MINIFLUX_ADMIN_PASSWORD') }}" gitea_db_password: "{{ lookup('env', 'GITEA_DB_PASSWORD') }}" goatcounter_version: "2.7.0" goatcounter_admin_email: "{{ lookup('env', 'GOATCOUNTER_ADMIN_EMAIL') }}" goatcounter_admin_password: "{{ lookup('env', 'GOATCOUNTER_ADMIN_PASSWORD') }}" hermes_openrouter_api_key: "{{ lookup('env', 'HERMES_OPENROUTER_API_KEY') }}" hermes_telegram_bot_token: "{{ lookup('env', 'HERMES_TELEGRAM_BOT_TOKEN') }}" hermes_telegram_allowed_users: "{{ lookup('env', 'HERMES_TELEGRAM_ALLOWED_USERS') }}" hermes_miniflux_api_key: "{{ lookup('env', 'HERMES_MINIFLUX_API_KEY') }}" wg_client_pubkey: "{{ lookup('env', 'WG_CLIENT_PUBKEY') }}" tasks: # ── System ────────────────────────────────────────────────────────────── - name: Update apt cache and upgrade packages apt: update_cache: true upgrade: dist cache_valid_time: 3600 - name: Install common dependencies apt: name: - debian-keyring - debian-archive-keyring - apt-transport-https - curl - ufw - unattended-upgrades state: present - name: Configure unattended-upgrades copy: dest: /etc/apt/apt.conf.d/50unattended-upgrades owner: root group: root mode: '0644' content: | Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}-security"; "${distro_id}ESMApps:${distro_codename}-apps-security"; "${distro_id}ESM:${distro_codename}-infra-security"; }; Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; - name: Enable automatic updates copy: dest: /etc/apt/apt.conf.d/20auto-upgrades owner: root group: root mode: '0644' content: | APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; # ── Caddy ─────────────────────────────────────────────────────────────── - name: Add Caddy GPG key shell: | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg args: creates: /usr/share/keyrings/caddy-stable-archive-keyring.gpg - name: Add Caddy apt repository shell: | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | tee /etc/apt/sources.list.d/caddy-stable.list args: creates: /etc/apt/sources.list.d/caddy-stable.list - name: Install Caddy apt: name: caddy state: present update_cache: true - name: Install Caddyfile copy: src: ../Caddyfile dest: /etc/caddy/Caddyfile owner: root group: caddy mode: '0640' notify: Restart Caddy tags: - miniflux - gitea - goatcounter - calibre - name: Enable and start Caddy systemd: name: caddy enabled: true state: started # ── Site directory ─────────────────────────────────────────────────────── - name: Create www system user user: name: www system: true create_home: false shell: /usr/sbin/nologin state: present # ── SSH hardening ─────────────────────────────────────────────────────── - name: Harden SSH configuration copy: dest: /etc/ssh/sshd_config.d/hardening.conf owner: root group: root mode: '0644' content: | PasswordAuthentication no PermitRootLogin prohibit-password MaxAuthTries 3 notify: Restart sshd # ── Fail2ban ──────────────────────────────────────────────────────────── - name: Install fail2ban apt: name: fail2ban state: present - name: Configure fail2ban SSH jail copy: dest: /etc/fail2ban/jail.local owner: root group: root mode: '0644' content: | [sshd] enabled = true port = ssh maxretry = 3 bantime = 3600 findtime = 600 notify: Restart fail2ban - name: Enable and start fail2ban systemd: name: fail2ban enabled: true state: started # ── UFW ───────────────────────────────────────────────────────────────── - name: Set UFW default incoming policy to deny ufw: default: deny direction: incoming - name: Set UFW default outgoing policy to allow ufw: default: allow direction: outgoing - name: Allow SSH ufw: rule: allow name: OpenSSH - name: Allow HTTP ufw: rule: allow port: '80' proto: tcp - name: Allow HTTPS ufw: rule: allow port: '443' proto: tcp - name: Allow Gitea SSH ufw: rule: allow port: '2222' proto: tcp - name: Enable UFW ufw: state: enabled # ── WireGuard ─────────────────────────────────────────────────────────── - name: Install WireGuard apt: name: wireguard state: present tags: wireguard - name: Generate WireGuard server private key shell: wg genkey > /etc/wireguard/server_privatekey && chmod 600 /etc/wireguard/server_privatekey args: creates: /etc/wireguard/server_privatekey tags: wireguard - name: Generate WireGuard server public key shell: cat /etc/wireguard/server_privatekey | wg pubkey > /etc/wireguard/server_publickey args: creates: /etc/wireguard/server_publickey tags: wireguard - name: Read server private key slurp: src: /etc/wireguard/server_privatekey register: wg_server_privkey tags: wireguard - name: Read server public key slurp: src: /etc/wireguard/server_publickey register: wg_server_pubkey tags: wireguard - name: Write WireGuard config copy: dest: /etc/wireguard/wg0.conf owner: root group: root mode: '0600' content: | [Interface] PrivateKey = {{ wg_server_privkey.content | b64decode | trim }} Address = 10.100.0.1/24 ListenPort = 51820 [Peer] PublicKey = {{ wg_client_pubkey }} AllowedIPs = 10.100.0.2/32 notify: Restart WireGuard tags: wireguard - name: Allow WireGuard UDP port ufw: rule: allow port: '51820' proto: udp tags: wireguard - name: Enable and start WireGuard systemd: name: wg-quick@wg0 enabled: true state: started tags: wireguard - name: Display server public key debug: msg: "WireGuard server public key: {{ wg_server_pubkey.content | b64decode | trim }}" tags: wireguard # ── Docker ────────────────────────────────────────────────────────────── - name: Create Docker keyring directory file: path: /etc/apt/keyrings state: directory mode: '0755' - name: Add Docker GPG key shell: | curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg args: creates: /etc/apt/keyrings/docker.gpg - name: Add Docker apt repository shell: | echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ | tee /etc/apt/sources.list.d/docker.list args: creates: /etc/apt/sources.list.d/docker.list - name: Install Docker apt: name: - docker-ce - docker-ce-cli - containerd.io - docker-buildx-plugin - docker-compose-plugin state: present update_cache: true - name: Enable Docker systemd: name: docker enabled: true state: started - name: Create shared Docker network command: docker network create monotrope register: docker_net changed_when: docker_net.rc == 0 failed_when: docker_net.rc != 0 and 'already exists' not in docker_net.stderr tags: - miniflux - hermes # ── Miniflux ──────────────────────────────────────────────────────────── - name: Create Miniflux directory file: path: /opt/miniflux state: directory owner: root group: root mode: '0750' tags: miniflux - name: Copy Miniflux docker-compose.yml copy: src: ../miniflux/docker-compose.yml dest: /opt/miniflux/docker-compose.yml owner: root group: root mode: '0640' tags: miniflux - name: Write Miniflux .env copy: dest: /opt/miniflux/.env owner: root group: root mode: '0600' content: | MINIFLUX_DB_PASSWORD={{ miniflux_db_password }} MINIFLUX_ADMIN_USER={{ miniflux_admin_user }} MINIFLUX_ADMIN_PASSWORD={{ miniflux_admin_password }} no_log: true tags: miniflux - name: Pull and start Miniflux command: docker compose up -d --pull always args: chdir: /opt/miniflux tags: miniflux # ── Gitea ─────────────────────────────────────────────────────────────── - name: Create Gitea directory file: path: /opt/gitea state: directory owner: root group: root mode: '0750' tags: gitea - name: Copy Gitea docker-compose.yml copy: src: ../gitea/docker-compose.yml dest: /opt/gitea/docker-compose.yml owner: root group: root mode: '0640' tags: gitea - name: Write Gitea .env copy: dest: /opt/gitea/.env owner: root group: root mode: '0600' content: | GITEA_DB_PASSWORD={{ gitea_db_password }} no_log: true tags: gitea - name: Pull and start Gitea command: docker compose up -d --pull always args: chdir: /opt/gitea tags: gitea # ── Hermes Agent ──────────────────────────────────────────────────────── - name: Create Hermes directory file: path: /opt/hermes state: directory owner: root group: root mode: '0750' tags: hermes - name: Copy Hermes docker-compose.yml copy: src: ../hermes/docker-compose.yml dest: /opt/hermes/docker-compose.yml owner: root group: root mode: '0640' tags: hermes - name: Stage Hermes config.yaml copy: src: ../hermes/config.yaml dest: /opt/hermes/config.yaml owner: root group: root mode: '0640' tags: hermes - name: Copy config.yaml into Hermes volume command: docker cp /opt/hermes/config.yaml hermes:/opt/data/config.yaml notify: Restart Hermes tags: hermes - name: Copy Hermes plugins copy: src: ../hermes/plugins/ dest: /opt/hermes/plugins/ owner: root group: root mode: '0640' directory_mode: '0750' notify: Restart Hermes tags: hermes - name: Write Miniflux plugin config copy: dest: /opt/hermes/plugins/miniflux/config.json owner: root group: root mode: '0600' content: | { "base_url": "http://miniflux:8080", "api_key": "{{ hermes_miniflux_api_key }}" } no_log: true notify: Restart Hermes tags: hermes - name: Write Hermes .env copy: dest: /opt/hermes/.env owner: root group: root mode: '0600' content: | OPENROUTER_API_KEY={{ hermes_openrouter_api_key }} TELEGRAM_BOT_TOKEN={{ hermes_telegram_bot_token }} TELEGRAM_ALLOWED_USERS={{ hermes_telegram_allowed_users }} no_log: true tags: hermes - name: Pull and start Hermes command: docker compose up -d --pull always args: chdir: /opt/hermes tags: hermes # ── Calibre (kobodl + calibre-web) ──────────────────────────────────── - name: Create Calibre directory file: path: /opt/calibre state: directory owner: root group: root mode: '0750' tags: calibre - name: Copy Calibre docker-compose.yml copy: src: ../calibre/docker-compose.yml dest: /opt/calibre/docker-compose.yml owner: root group: root mode: '0640' tags: calibre - name: Pull and start Calibre services command: docker compose up -d --pull always args: chdir: /opt/calibre tags: calibre - name: Fix downloads volume ownership command: > docker compose exec -T kobodl chown 1000:1000 /downloads args: chdir: /opt/calibre tags: calibre - name: Check if Calibre library exists command: > docker compose exec -T calibre-web test -f /library/metadata.db args: chdir: /opt/calibre register: calibre_db_check changed_when: false failed_when: false tags: calibre - name: Initialise Calibre library command: > docker compose exec -T --user abc calibre-web calibredb add --empty --with-library /library/ args: chdir: /opt/calibre when: calibre_db_check.rc != 0 tags: calibre - name: Install calibre-sync script copy: dest: /opt/calibre/sync.sh owner: root group: root mode: '0755' content: | #!/bin/bash set -euo pipefail cd /opt/calibre # Download all books from Kobo docker compose exec -T kobodl kobodl --config /home/config/kobodl.json book get --get-all --output-dir /downloads # Import any new EPUBs into Calibre library docker compose exec -T calibre-web sh -c ' for f in /downloads/*.epub; do [ -f "$f" ] || continue calibredb add "$f" --with-library /library/ && rm "$f" done ' tags: calibre # ── GoatCounter ───────────────────────────────────────────────────────── - name: Create goatcounter system user user: name: goatcounter system: true create_home: false shell: /usr/sbin/nologin state: present tags: goatcounter - name: Create GoatCounter data directory file: path: /var/lib/goatcounter state: directory owner: goatcounter group: goatcounter mode: '0750' tags: goatcounter - name: Download GoatCounter binary get_url: url: "https://github.com/arp242/goatcounter/releases/download/v{{ goatcounter_version }}/goatcounter-v{{ goatcounter_version }}-linux-amd64.gz" dest: /tmp/goatcounter.gz mode: '0644' checksum: "sha256:98d221cb9c8ef2bf76d8daa9cca647839f8d8b0bb5bc7400ff9337c5da834511" tags: goatcounter - name: Decompress GoatCounter binary shell: gunzip -f /tmp/goatcounter.gz && mv /tmp/goatcounter /usr/local/bin/goatcounter && chmod 0755 /usr/local/bin/goatcounter args: creates: /usr/local/bin/goatcounter tags: goatcounter - name: Check if GoatCounter admin user has been created stat: path: /var/lib/goatcounter/.admin_created register: goatcounter_admin_marker tags: goatcounter - name: Create GoatCounter admin user command: > goatcounter db create site -db sqlite+/var/lib/goatcounter/goatcounter.sqlite3 -createdb -vhost stats.monotrope.au -user.email {{ goatcounter_admin_email }} -user.password {{ goatcounter_admin_password }} become_user: goatcounter when: not goatcounter_admin_marker.stat.exists no_log: true tags: goatcounter - name: Mark GoatCounter admin user as created file: path: /var/lib/goatcounter/.admin_created state: touch owner: goatcounter group: goatcounter mode: '0600' when: not goatcounter_admin_marker.stat.exists tags: goatcounter - name: Install GoatCounter systemd service copy: dest: /etc/systemd/system/goatcounter.service owner: root group: root mode: '0644' content: | [Unit] Description=GoatCounter analytics After=network.target [Service] User=goatcounter Group=goatcounter ExecStart=/usr/local/bin/goatcounter serve \ -listen localhost:8081 \ -db sqlite+/var/lib/goatcounter/goatcounter.sqlite3 \ -tls none \ -domain stats.monotrope.au Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target notify: Restart GoatCounter tags: goatcounter - name: Enable and start GoatCounter systemd: name: goatcounter enabled: true state: started daemon_reload: true tags: goatcounter # ── Deploy user ────────────────────────────────────────────────────────── - name: Create deploy user user: name: "{{ deploy_user }}" create_home: true shell: /bin/bash state: present - name: Set up deploy user SSH directory file: path: "/home/{{ deploy_user }}/.ssh" state: directory owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: '0700' - name: Install deploy user SSH public key ansible.posix.authorized_key: user: "{{ deploy_user }}" key: "{{ deploy_pubkey }}" state: present - name: Create site directory file: path: "{{ site_dir }}" state: directory owner: "{{ deploy_user }}" group: www mode: '0775' handlers: - name: Restart Caddy systemd: name: caddy state: restarted - name: Restart GoatCounter systemd: name: goatcounter state: restarted - name: Restart sshd systemd: name: ssh state: restarted - name: Restart fail2ban systemd: name: fail2ban state: restarted - name: Restart Hermes command: docker compose restart args: chdir: /opt/hermes - name: Restart WireGuard systemd: name: wg-quick@wg0 state: restarted