← Back to projects Project 03 / 04
Case study · Self-hosting

This portfolio, self-hosted

The site you are reading runs on a Debian server at my home, behind my internet router, on the m3tsh.com domain. Mandatory, automatic HTTPS, no internal service exposed, and an isolated contact API that notifies me on Telegram.

Try the contact form →
Role
Design, deployment & operations
Context
Personal project · 2026
Hosting
Debian server at home
Stack
Docker · Caddy · Python
DNS IONOS m3tsh.com → A VISITOR browser LET'S ENCRYPT TLS-ALPN-01 challenge ROUTER NAT 443→4567 · 80→8080 HTTPS 443 DEBIAN SERVER · DOCKER COMPOSE · “web” NETWORK :4567 Caddy TLS · HEADERS · CSP REVERSE PROXY STATIC SITE HTML · CSS · JS mounted read-only CONTACT API Python · standard library non-root user messages VOLUME / /api/* TELEGRAM Bot API notification REAL ARCHITECTURE · ONLY CADDY IS EXPOSED · NO INTERNAL SERVICE REACHABLE FROM THE INTERNET
01 — Goals

Host it myself,
expose nothing unnecessary.

Rather than a turnkey host, I wanted a site that is itself a demonstration of what I can do:

  • Self-hosted on a Debian server at home, behind a consumer internet router.
  • HTTPS everywhere, with a Let's Encrypt certificate renewed automatically.
  • Minimal attack surface: only Caddy is reachable from the Internet (port 443, plus port 80 to redirect to HTTPS), no internal service reachable.
  • A contact form that actually works, with no third-party service on the visitor side.
  • Reproducible: the whole deployment fits in one repository and one command.
02 — Architecture

A single entry point.

The m3tsh.com domain (DNS at IONOS) points to my router's public IP. The router forwards port 443 to port 4567 on the server, where Caddy, the only published container, listens. Port 80 also reaches Caddy, which only redirects to HTTPS.

Caddy terminates TLS, adds the security headers, serves the static site and forwards /api/* requests to the contact API. That API is only reachable on the internal Docker network: it has no published port.

$ exposed ports 443 → 4567Caddy · HTTPS OPEN 80 → 8080HTTP · 301 redirect to HTTPS REDIRECT 8000contact API · Docker network INTERNAL
Exposed surface — Caddy only
FORM CADDY16 KB max APIchecks · 5/h/IP JSONL TELEGRAM POST /api/contact
Path of a contact message
03 — Implementation

Two containers, one command.

Everything is described in a Docker Compose file; sensitive settings (domain, bot token) live in an unversioned .env file. A single docker compose up -d brings everything back up.

# docker-compose.yml (summary)
caddy:
  image:    caddy:2.10
  ports:    "4567:4567"        # HTTPS (443 on the router)
            "8080:8080"        # HTTP → HTTPS redirect
  volumes:  ./site:/srv/site:ro  # site mounted read-only
            caddy_data:/data     # certificates
contact:
  build:    ./contact          # python:3.13-alpine
  expose:   "8000"             # internal network only
  # runs as non-root (uid 10001, set in the Dockerfile)
  volumes:  messages:/data
  env:      TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
  • Certificate: Caddy obtains it on its own through the TLS-ALPN-01 challenge, which uses port 443. Port 80 is only used to redirect visitors to HTTPS.
  • Domain: www.m3tsh.com redirects with a 301 to m3tsh.com.
  • Form: the API (Python, standard library only) stores each message in a volume, then notifies me through the Telegram Bot API.
  • Performance: zstd/gzip compression and browser caching for static files.
04 — Security

Defence in depth,
even for a small site.

  • Network: only ports 443 and 80 are forwarded, both to Caddy (80 only redirects to HTTPS); the API is not published on any host port.
  • HTTP headers: HSTS, strict Content-Security-Policy (no external or inline scripts), X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, Server header removed.
  • Form: request body capped at 16 KB by Caddy, server-side field validation, a honeypot field against bots, and a limit of 5 messages per hour per IP.
  • Containers: the API runs as a non-root user, the site is mounted read-only, and there are no external Python dependencies to maintain.
  • Secrets: .env file with 600 permissions, excluded from the repository.
05 — AI agent

Deployed by directing
an AI agent.

This site was designed and deployed by working with Claude Code, an AI agent that operates directly in the server's terminal: it reads files, runs commands and proposes changes, which I approve one by one.

My role: set the goals and constraints, make the architecture decisions, review every action and check the result. A few concrete examples from the deployment:

  • Moving to my own domain: dropping dynamic DNS and switching to a certificate method that does not need port 80.
  • TLS error at launch: traced to the router's DNS cache still pointing to the old IP.
  • Emails not arriving: the SMTP relay was blocking the server's IP; I switched to Telegram notifications, which are simpler and more reliable.
  • Mobile layout: automated screenshots in a headless browser to spot and fix issues.
  • Missing notifications: the configuration was right, but the container had been restarted without rebuilding its image, so it was still running the old code; fixed with docker compose up -d --build.

Knowing how a language model works (context, tools, limits) makes it possible to hand it real administration tasks while staying in control.

1Exposed service (Caddy)
2Containers
0External dependencies (API)