/images/avatar.png

Break things. Fix them. Learn.

This site runs on an Intel NUC hosted at home, behind a standard fiber connection. Its main purpose is to serve as an experimentation ground for testing server configurations, automation scripts, and open source security tools.

Not a professional website — a homelab: we break things, fix them, and learn.

Migration in progress
The site is gradually migrating from Grav CMS to Hugo. Old URLs are preserved, but the visual rendering is evolving. If you spot a bug, report it.

🛠️ Tech stack

ToolRoleLink
🔒 CrowdSecCommunity IDS/IPSDashboard
☁️ CloudflareCDN · WAF · DNS · DDoSDashboard
📊 BetterStackMonitoring · Alerts · LogsStatus page
🌐 HugoStatic site generatorgohugo.io
🛡️ ModSecurityLocal WAF · OWASP CRS 4.xOWASP CRS
nginxReverse proxy · TLS 1.3nginx.org

🌟 Don’t miss

Three articles that capture the spirit of this homelab:

📚 Full documentation is in Documentation and automation scripts in Scripts.

🐛 Found a vulnerability?

If you discover a bug, misconfiguration, or security vulnerability on this server, please report it. This homelab is public and I learn from my mistakes.

📨 Responsible disclosure: www.arleo.eu/security.txt

Any contribution to improving security is welcome.

MCP security sprint delivered: v1.9.0, 10 chantiers, hardened ecosystem

TL;DR

On May 9, 2026, I delivered all 10 chantiers of the MCP security sprint that I had announced earlier in the day in a single marathon session. hugo-mcp is now at v1.9.0 (GitHub Release), commit 1404f83 GPG-signed.

Here’s the high-level recap + a pedagogical deep-dive on 2 chantiers with real value beyond my specific context: C2 token rotation and C6 internal TLS.

Recap of 10 chantiers

#ChantierImplementation
C1Rate limitingslowapi, 60 req/min per IP
C2Token rotationtokens.json + token_mgr.py CLI
C3JSON audit logsstructlog, machine-readable events
C4Strict Pydantic v2CreatePageArgs / UpdatePageArgs with constraints
C5bcrypt cost-12Tokens hashed in storage
C6NUC ↔ VM TLSEC P-256 cert, uvicorn SSL, proxy verifies the cert
C7requirements.lockSHA-256 hashes via pip-compile --generate-hashes
C8Info disclosureDocs off, generic exception handler, proxy_hide_header
C9nginx WAFPOST + application/json enforcement on /mcp, OWASP CRS active
C10Backup DRbackup.sh GPG-encrypted, 30-day retention

Full details in the CHANGELOG v1.9.0 and commit 1404f83.

Post-mortem: Cloudflare Bot Management blocked MCP webhooks

The symptom

I just finished a webhook endpoint in hugo-mcp-proxy that will receive notifications from GitHub on every push to the arleo.eu repo. Clean implementation: HMAC-SHA256, rate limiting, IPAddressAllow GitHub ranges in systemd.

Functional test from an external client:

$ curl -X POST https://mcp-hugo.arleo.eu/webhook/test \
    -H "Content-Type: application/json" \
    -d '{"test": true}'

Response: 403 Forbidden.

Strange. The service is running, my source IP is whitelisted, the HMAC is correct. Why 403?

Server-side investigation

NUC nginx logs:

$ sudo tail -100 /var/log/nginx/mcp-hugo.access.log | grep webhook

Empty. No request reaches nginx.

mcp-oauth-proxy logs:

$ sudo journalctl -u mcp-oauth-proxy -n 100 | grep webhook

Empty too. The request doesn’t even reach the service.

Either it’s blocked by the firewall before nginx (CrowdSec or ufw), or upstream by Cloudflare.

The truth at Cloudflare

Post-mortem: mcp-installer regenerated tokens on every rerun

The bug

mcp-installer is a bash script I wrote to automate the installation of mcp-oauth-proxy (FastAPI + nginx + systemd) on a new host. Standard workflow: clone, run, done.

Except when re-running the script on an already installed host (e.g. to update the version), I discovered an idempotence bug: all secrets were regenerated.

$ sudo ./install.sh
[+] Generating MCP_TOKEN...
[+] Generating CLIENT_ID...
[+] Generating CLIENT_SECRET...
[+] Generating TOKEN_SECRET...
[+] Writing /etc/mcp-oauth-proxy/.env...

If you already have a .env with tokens in service, the installer overwrites them. All already-registered OAuth clients (Claude.ai in my case) end up with invalid credentials. Connection breaks.

Why it happened

The script used this logic:

MCP_TOKEN=$(openssl rand -hex 32)
CLIENT_ID=$(openssl rand -hex 16)
CLIENT_SECRET=$(openssl rand -hex 32)
TOKEN_SECRET=$(openssl rand -hex 32)

cat > /etc/mcp-oauth-proxy/.env <<EOF
MCP_TOKEN=$MCP_TOKEN
CLIENT_ID=$CLIENT_ID
CLIENT_SECRET=$CLIENT_SECRET
TOKEN_SECRET=$TOKEN_SECRET
EOF

The “generate then write” pattern is correct for first install. But on rerun, it completely ignores the existing .env.

It’s the classic mistake: the author (me) tested the script only on a fresh host. The “reinstall on existing host” mode was never tested.

Bug reproduction

$ sudo ./install.sh
[OK] Installation complete
$ cat /etc/mcp-oauth-proxy/.env | head -1
MCP_TOKEN=a7f3e9d2c4b8a1...

$ sudo ./install.sh
[OK] Installation complete
$ cat /etc/mcp-oauth-proxy/.env | head -1
MCP_TOKEN=8c2f6a1b9e4d7c...   ← DIFFERENT

Reproducible test in 30 seconds. Almost comical I didn’t catch it sooner.

The lesson: “idempotent” is a contract, not a hope

An installation script must be idempotent by default. Meaning: ./install.sh once, twice, five times → system in the same stable state.

I had this contract in my head. I didn’t have it in the code.

The fix: pre-flight checks + explicit force flag

Strategy 4: separating content (MCP) from structure (Git)

The problem

You have a Hugo site. You want to:

  1. Edit content via Claude.ai (publish an article, fix a typo, update a draft) without touching an SSH terminal.
  2. Version the structure (layouts, themes, hugo.toml, deploy scripts) in Git, like a serious dev.

First instinct: “everything in Git”. Articles too. The MCP commits, pushes, GitHub webhook triggers a rebuild. Clean, GitOps-philosophy.

Except it doesn’t work that well. Here’s why, and the simple solution I call Strategy 4.

Why “everything in Git” breaks in practice

Imagine your MCP git commits on every create_page. Naive strategy, often suggested. Here are the problems:

Problem 1 — MCP ↔ Git conflict

You push a new layout from your laptop (layouts/index.html modified). At the same time, the MCP is committing a new article version. Race condition: the MCP might git pull --rebase and fail, or worse, overwrite your local commit.

Problem 2 — MCP’s Git identity

Whose commits is the MCP making? With which GPG key? If you have a “signed commits required” policy, the MCP needs to manage a GPG key, which has to be secured, rotated, etc.

Problem 3 — Unwanted auto-commits

You’re testing, you create a draft article to experiment, you delete it. But the MCP already committed. Now you have a “wip test” commit in history, to rebase or squash manually.

Problem 4 — Asymmetric reversibility

A git revert repo-side has no effect on files the MCP already created. You end up with a desynchronized repo and filesystem state.

Strategy 4: separate the zones

The idea: MCP and Git never write to the same files.

ZoneWho editsVersioned in Git?
content/**/*.mdMCP only❌ NO (.gitignore)
layouts/, themes/, static/, hugo.toml, deploy.shGit push only✅ YES

Repo-side .gitignore:

content/
public/
resources/

Implications:

  • The MCP can write to content/ whenever. No Git conflict possible.
  • You can git reset --hard repo-side with confidence — content/ stays intact.
  • No need for the MCP to manage a Git identity.
  • No “commit pollution”.

Trade-off: no content versioning

You lose Git versioning of content. That’s a real loss:

  • No git blame on an article to see who wrote what.
  • No git log content/csp-nonce/index.fr.md for history.
  • No PR review for articles.

Mitigation: VM snapshots + daily encrypted content/ backup to QNAP. That covers disaster recovery, but not fine-grained versioning (who-changed-what-when).

For my personal homelab (single author, technical articles, no editorial validation workflow), it’s an acceptable trade-off. For a 10-contributor team blog, I’d reconsider.

Final architecture

systemd hardening: taking a Python service from 9.6 to 1.7

TL;DR

systemd-analyze security is an underused tool. It scans your unit files and computes an exposure score from 0.0 (UNSAFE) to 10.0 (PERFECT). Custom Python services often score around 9.6 by default — that’s bad.

I took my hugo-mcp service (FastAPI exposing 7 MCP tools) from 9.6 → 1.7 without breaking a single feature. Here are the directives that actually matter, and the ones that are traps.

Initial score

Hugo