Files
monotrope/infra/ansible/playbook.yml
Louis Simoneau bbeecde448 Add shared Docker network and Miniflux plugin for Hermes
- Create external 'monotrope' Docker network so services can
  communicate by container name
- Add Miniflux to the shared network (db stays on internal network)
- Add Hermes Miniflux plugin with list_feeds and get_unread_entries tools
- Mount plugin directory and pass Miniflux API key to Hermes container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:16:34 +10:00

543 lines
16 KiB
YAML

---
- 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') }}"
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
- 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: 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: 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 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 }}
MINIFLUX_API_KEY={{ hermes_miniflux_api_key }}
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