Add Hermes agent, self-host fonts, new blog post
- Add Hermes (Nous Research LLM agent) with Telegram gateway, Ansible provisioning, and Makefile targets - Self-host JetBrains Mono and Spectral fonts (remove Google Fonts) - Add "An Experiment in Self-Hosting" blog post - Update CLAUDE.md with high-level server overview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
CLAUDE.md
11
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`
|
||||
|
||||
11
Makefile
11
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
|
||||
|
||||
@@ -24,6 +24,8 @@ monotrope.au {
|
||||
path *.html / /posts/ /posts/*
|
||||
}
|
||||
header @html Cache-Control "public, max-age=0, must-revalidate"
|
||||
|
||||
|
||||
}
|
||||
|
||||
# Redirect www to apex
|
||||
|
||||
@@ -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
|
||||
|
||||
10
infra/hermes/config.yaml
Normal file
10
infra/hermes/config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
model:
|
||||
provider: openrouter
|
||||
default: openrouter/auto
|
||||
|
||||
memory:
|
||||
memory_enabled: true
|
||||
user_profile_enabled: true
|
||||
|
||||
agent:
|
||||
max_turns: 70
|
||||
23
infra/hermes/docker-compose.yml
Normal file
23
infra/hermes/docker-compose.yml
Normal file
@@ -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:
|
||||
19
site/content/posts/self-hosting.md
Normal file
19
site/content/posts/self-hosting.md
Normal file
@@ -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.
|
||||
@@ -6,9 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}</title>
|
||||
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="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" rel="stylesheet">
|
||||
<link rel="preload" href="/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/spectral-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
{{ range .AlternativeOutputFormats -}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/jetbrains-mono-italic-400-latin.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-italic-400-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/jetbrains-mono-latin-ext.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/jetbrains-mono-latin.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-400-latin-ext.woff2
Normal file
BIN
site/static/fonts/spectral-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-400-latin.woff2
Normal file
BIN
site/static/fonts/spectral-400-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-600-latin-ext.woff2
Normal file
BIN
site/static/fonts/spectral-600-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-600-latin.woff2
Normal file
BIN
site/static/fonts/spectral-600-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-italic-400-latin-ext.woff2
Normal file
BIN
site/static/fonts/spectral-italic-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-italic-400-latin.woff2
Normal file
BIN
site/static/fonts/spectral-italic-400-latin.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user