--- - 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') }}" 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 - 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 # ── 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 # ── 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: Copy Hermes config.yaml copy: src: ../hermes/config.yaml dest: /opt/hermes/config.yaml owner: root group: root mode: '0640' 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 # ── 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