diff --git a/CLAUDE.md b/CLAUDE.md
index 1657f29..bdcccee 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,17 @@ CSS. The design should feel minimal, typographic, and monospaced-first.
DigitalOcean droplet, Sydney region, Ubuntu 24.04 LTS.
+### What's on the server
+
+- **Hugo static site** — built locally, rsynced to `/var/www/monotrope`
+- **Caddy** — reverse proxy and TLS for all services
+- **Miniflux** — RSS reader (Docker, PostgreSQL)
+- **Gitea** — self-hosted git server (Docker, PostgreSQL, SSH on port 2222)
+- **GoatCounter** — privacy-friendly analytics (native binary, SQLite)
+- **Hermes Agent** — Nous Research's LLM agent (`nousresearch/hermes-agent`),
+ exposed via Telegram bot. Routes through OpenRouter. Used as a personal
+ assistant reachable from mobile. Docker, config in `infra/hermes/`.
+
## Conventions
- All shell scripts use `set -euo pipefail`
diff --git a/Makefile b/Makefile
index 8479b5a..1953fb4 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build serve deploy ssh setup miniflux gitea goatcounter enrich
+.PHONY: build serve deploy ssh setup miniflux gitea goatcounter hermes hermes-chat enrich
# Load .env if it exists
-include .env
@@ -37,5 +37,14 @@ goatcounter:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags goatcounter
+hermes:
+ @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
+ ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags hermes
+
+hermes-chat:
+ @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
+ ssh -t root@$(MONOTROPE_HOST) docker exec -it hermes hermes chat
+
+
enrich:
uv run enrich.py
diff --git a/infra/Caddyfile b/infra/Caddyfile
index db3df81..7eec188 100644
--- a/infra/Caddyfile
+++ b/infra/Caddyfile
@@ -24,6 +24,8 @@ monotrope.au {
path *.html / /posts/ /posts/*
}
header @html Cache-Control "public, max-age=0, must-revalidate"
+
+
}
# Redirect www to apex
diff --git a/infra/ansible/playbook.yml b/infra/ansible/playbook.yml
index f7d2f05..262cbed 100644
--- a/infra/ansible/playbook.yml
+++ b/infra/ansible/playbook.yml
@@ -14,6 +14,9 @@
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:
@@ -311,6 +314,55 @@
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
@@ -461,3 +513,8 @@
systemd:
name: fail2ban
state: restarted
+
+ - name: Restart Hermes
+ command: docker compose restart
+ args:
+ chdir: /opt/hermes
diff --git a/infra/hermes/config.yaml b/infra/hermes/config.yaml
new file mode 100644
index 0000000..803ced4
--- /dev/null
+++ b/infra/hermes/config.yaml
@@ -0,0 +1,10 @@
+model:
+ provider: openrouter
+ default: openrouter/auto
+
+memory:
+ memory_enabled: true
+ user_profile_enabled: true
+
+agent:
+ max_turns: 70
\ No newline at end of file
diff --git a/infra/hermes/docker-compose.yml b/infra/hermes/docker-compose.yml
new file mode 100644
index 0000000..33c4abc
--- /dev/null
+++ b/infra/hermes/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ hermes:
+ image: nousresearch/hermes-agent:latest
+ container_name: hermes
+ restart: unless-stopped
+ command: gateway run
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+ volumes:
+ - hermes_data:/opt/data
+ - ./config.yaml:/opt/data/config.yaml:ro
+ environment:
+ OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}"
+ TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}"
+ TELEGRAM_ALLOWED_USERS: "${TELEGRAM_ALLOWED_USERS}"
+ env_file:
+ - .env
+
+volumes:
+ hermes_data:
diff --git a/site/content/posts/self-hosting.md b/site/content/posts/self-hosting.md
new file mode 100644
index 0000000..5c2451c
--- /dev/null
+++ b/site/content/posts/self-hosting.md
@@ -0,0 +1,19 @@
+---
+title: "An Experiment in Self-Hosting"
+date: 2026-04-10T00:00:00+10:00
+draft: false
+---
+
+One of the things I wanted to do with this site is to see how much tooling I could self-host on a small VPS, in particular with the acceleration afforded by AI coding through Claude Code.
+
+So far I have:
+
+* A [Hugo](https://gohugo.io/) static site
+* [Caddy](https://caddyserver.com/) webserver
+* Self-hosted feed reader with [Miniflux](https://miniflux.app/)
+* Analytics using [Goatcounter](https://www.goatcounter.com/)
+* Git server using [Gitea](https://about.gitea.com/) (you can check out the source for the whole project, inception-style, at [git.monotrope.au/louis/monotrope](https://git.monotrope.au/louis/monotrope))
+
+The only external dependency for the whole setup is the server itself (a DigitalOcean droplet), and I'm sure I'll come up with more tools I can add to the server over time.
+
+All of this I would estimate took less than 4 hours to set up and deploy. I think previously even the small amount of effort required to deploy a static blog would have pushed me towards free platforms like Medium or GitHub Pages. This mode of production has the potential be a great thing for the Web if more tinkerers can build and host their own stuff instead of relying on centralised platforms where you are the product.
\ No newline at end of file
diff --git a/site/layouts/_default/baseof.html b/site/layouts/_default/baseof.html
index 1ee4621..6c13197 100644
--- a/site/layouts/_default/baseof.html
+++ b/site/layouts/_default/baseof.html
@@ -6,9 +6,8 @@
{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}
-
-
-
+
+
{{ range .AlternativeOutputFormats -}}
diff --git a/site/static/css/main.css b/site/static/css/main.css
index 37ed7b0..ce479d8 100644
--- a/site/static/css/main.css
+++ b/site/static/css/main.css
@@ -1,5 +1,96 @@
/* ── Fonts ─────────────────────────────────────── */
-@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap');
+
+/* JetBrains Mono — latin-ext */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 300 500;
+ font-display: swap;
+ src: url('/fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* JetBrains Mono — latin */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 300 500;
+ font-display: swap;
+ src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* JetBrains Mono italic — latin-ext */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/jetbrains-mono-italic-400-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* JetBrains Mono italic — latin */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/jetbrains-mono-italic-400-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+/* Spectral — latin-ext */
+@font-face {
+ font-family: 'Spectral';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/spectral-400-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* Spectral — latin */
+@font-face {
+ font-family: 'Spectral';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/spectral-400-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* Spectral 600 — latin-ext */
+@font-face {
+ font-family: 'Spectral';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/fonts/spectral-600-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* Spectral 600 — latin */
+@font-face {
+ font-family: 'Spectral';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('/fonts/spectral-600-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* Spectral italic — latin-ext */
+@font-face {
+ font-family: 'Spectral';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/spectral-italic-400-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* Spectral italic — latin */
+@font-face {
+ font-family: 'Spectral';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/spectral-italic-400-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
/* ── Reset ─────────────────────────────────────── */
*, *::before, *::after {
diff --git a/site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2 b/site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2
new file mode 100644
index 0000000..c88b32d
Binary files /dev/null and b/site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2 differ
diff --git a/site/static/fonts/jetbrains-mono-italic-400-latin.woff2 b/site/static/fonts/jetbrains-mono-italic-400-latin.woff2
new file mode 100644
index 0000000..3d3c5d7
Binary files /dev/null and b/site/static/fonts/jetbrains-mono-italic-400-latin.woff2 differ
diff --git a/site/static/fonts/jetbrains-mono-latin-ext.woff2 b/site/static/fonts/jetbrains-mono-latin-ext.woff2
new file mode 100644
index 0000000..01769d9
Binary files /dev/null and b/site/static/fonts/jetbrains-mono-latin-ext.woff2 differ
diff --git a/site/static/fonts/jetbrains-mono-latin.woff2 b/site/static/fonts/jetbrains-mono-latin.woff2
new file mode 100644
index 0000000..cd5102a
Binary files /dev/null and b/site/static/fonts/jetbrains-mono-latin.woff2 differ
diff --git a/site/static/fonts/spectral-400-latin-ext.woff2 b/site/static/fonts/spectral-400-latin-ext.woff2
new file mode 100644
index 0000000..78c4c00
Binary files /dev/null and b/site/static/fonts/spectral-400-latin-ext.woff2 differ
diff --git a/site/static/fonts/spectral-400-latin.woff2 b/site/static/fonts/spectral-400-latin.woff2
new file mode 100644
index 0000000..71700f8
Binary files /dev/null and b/site/static/fonts/spectral-400-latin.woff2 differ
diff --git a/site/static/fonts/spectral-600-latin-ext.woff2 b/site/static/fonts/spectral-600-latin-ext.woff2
new file mode 100644
index 0000000..16fb98f
Binary files /dev/null and b/site/static/fonts/spectral-600-latin-ext.woff2 differ
diff --git a/site/static/fonts/spectral-600-latin.woff2 b/site/static/fonts/spectral-600-latin.woff2
new file mode 100644
index 0000000..3f56161
Binary files /dev/null and b/site/static/fonts/spectral-600-latin.woff2 differ
diff --git a/site/static/fonts/spectral-italic-400-latin-ext.woff2 b/site/static/fonts/spectral-italic-400-latin-ext.woff2
new file mode 100644
index 0000000..5295b22
Binary files /dev/null and b/site/static/fonts/spectral-italic-400-latin-ext.woff2 differ
diff --git a/site/static/fonts/spectral-italic-400-latin.woff2 b/site/static/fonts/spectral-italic-400-latin.woff2
new file mode 100644
index 0000000..1e0f383
Binary files /dev/null and b/site/static/fonts/spectral-italic-400-latin.woff2 differ