This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A self-hosted Ghost blog deployed with Docker Compose. There is no application code — the repo is infrastructure config only:
docker-compose.yml— four services:ghost(custom image),mysql(MySQL 8),caddy(reverse proxy / TLS),umami(self-hosted analytics, dashboard on port 8080, shares the MySQL instance via a dedicatedumamidatabase)ghost/Dockerfile— extendsghost:6-alpine; adds bash, curl, tzdata, mariadb-client and a healthcheckcaddy/Caddyfile— single site block; the address comes fromCADDY_SITE_ADDRESS(default:80for local, a domain in prod → automatic Let's Encrypt)Makefile—dev,prod(validates.envbefore launching),down,logs,ps.env(from.env.example, git-ignored) —GHOST_URL,CADDY_SITE_ADDRESS+ MySQL credentials; compose fails fast if passwords are unset
Two Docker networks isolate traffic: web (caddy ↔ ghost/umami) and internal (ghost/umami ↔ mysql). MySQL is never exposed to caddy or the host. Ghost and umami wait on mysql's healthcheck before starting. Caddy is the only service publishing ports (80/443 for Ghost, 8080 for the Umami dashboard + tracker — the tracking script is wired into Ghost via the codeinjection_head setting).
All persistent state lives in bind mounts under ./data/ (git-ignored): data/ghost-content, data/mysql, data/caddy/data, data/caddy/config. Keep these directories pre-created — if Docker creates them they're owned by root and Ghost (uid 1000) can't write its content dir.
MySQL runs with --default-authentication-plugin=mysql_native_password because Ghost requires the classic auth plugin — don't remove it.
make dev # build & start locally (requires .env)
make prod # production launch — checks GHOST_URL/CADDY_SITE_ADDRESS first
make logs / make ps / make down
docker compose config --quiet # validate compose file (needs MYSQL_* vars set)
docker compose pull && docker compose build --pull && docker compose up -d # updateSite: http://localhost/ — admin: http://localhost/ghost.
Going to production: currently IP-only — GHOST_URL=http://<public-ip> in .env, CADDY_SITE_ADDRESS unset (plain HTTP on :80), make prod. Once a domain exists: GHOST_URL=https://<domain> + CADDY_SITE_ADDRESS=<domain>, open ports 80/443 (see readme.md for the full procedure, plus backup commands).
- Host:
37.59.103.153(OVH, eu-west, Ubuntu cloud image), domainlandes-insoumises.fr(+www), DNS on OVH nameservers. - URL: served over HTTPS —
GHOST_URL=https://landes-insoumises.fr,CADDY_SITE_ADDRESS=landes-insoumises.fr www.landes-insoumises.fr. Caddy auto-manages the Let's Encrypt cert;wwwand:80redirect to the apex HTTPS site. - Ghost is public (private mode OFF; newsletter signup open to all). Toggle in Ghost Admin → Settings → Access. Ghost members and forum accounts are unrelated — the forum is standalone (see below).
- SSH:
ssh ubuntu@37.59.103.153 -i ~/.ssh/id_lfi— login user isubuntu, key-only auth. Theid_lfied25519 keypair lives on this workstation (~/.ssh/id_lfi/.pub); its public key is authorized on the box. - If you hit
Too many authentication failures, the client is offering too many keys before the right one — force it: add-o IdentitiesOnly=yes, or use aHostentry in~/.ssh/configpinningIdentityFile ~/.ssh/id_lfi.
The live stack lives in ~/lfi on the server. It tracks this repo (Ghost + MySQL + Caddy + Umami), plus a few server-only additions — so edit prod config on the server and recreate there (docker compose up -d <svc>, or docker compose restart caddy after a Caddyfile change — caddy reload fails silently in this setup); don't assume a local make prod reproduces it:
- Transactional email (SMTP) is configured via a server-side
docker-compose.override.ymlthat addsmail__*env to theghostservice; values live in the server.env(MAIL_FROM,SMTP_HOST/PORT/SECURE/USER/PASS). Provider: Mailjet (in-v3.mailjet.com:465SSL), sending asnoreply@landes-insoumises.fr. This covers staff invites + member magic-links (NOT bulk newsletters — that's Mailgun-only in Ghost). Domain is SPF+DKIM authenticated (spf.mailjet.com,mailjet._domainkey). Secrets are only in the server.env— never commit them. - Forum (Discourse, standalone) — a private team forum at
forum.landes-insoumises.fr, installed OUTSIDE this compose stack via Discourse's own launcher in/var/discourse(standalone container, Postgres+Redis embedded), joined to thelfi_webnetwork so Caddy can proxy it (server-onlyforum.site block in the prod Caddyfile). Accounts are Discourse-native:login_required+invite_only(admins invite people from Discourse). The former Ghost-SSO bridge (Discourse-on-Ghost connector, Équipe tier gate, team invite console) was removed on 2026-09-05 — do not reintroduce a Ghost↔forum link. Runbook:docs/forum-discourse.md. - Auto-publishing to Facebook/Instagram (n8n) — planned but NOT deployed; implementation plan in
docs/n8n-social-publishing.md. - Theme = the stock Source bundled with the Ghost image, via symlink
data/ghost-content/themes/source -> /var/lib/ghost/current/content/themes/source(translated since Ghost 6; the fr locale works out of the box). Historical note: under Ghost 5 a manual Source v1.6.1 copy was needed for French — leftoverssource-161.manual/source-171.brokennext to the symlink are safe to delete.
- OVH Manager → server → Boot → Rescue → reboot. OVH emails temporary
rootrescue credentials (short-lived — don't commit them). - SSH in as rescue
root. If the client won't reach the password prompt, force password-only:ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new root@37.59.103.153. - Disk layout:
sda1= real root (cloudimg-rootfs),sda16=/boot,sda15= EFI,sdb1= the rescue OS (ignore). Mount + chroot:mount /dev/sda1 /mnt && mount /dev/sda16 /mnt/boot && mount /dev/sda15 /mnt/boot/efi for d in dev proc sys run; do mount --rbind /$d /mnt/$d; done chroot /mnt /bin/bash
- Fix auth (add pubkey to
/home/ubuntu/.ssh/authorized_keys,chown ubuntu:ubuntu,chmod 700dir /600file; orpasswd ubuntu). exit,umount -R /mnt, then OVH Manager → Boot → Hard disk → reboot.
To copy text to the local clipboard, pipe data to the appropriate command.
- macOS:
echo "text" | pbcopy - Linux (X11):
echo "text" | xclip -selection clipboard - Windows:
echo "text" | clip - WSL2:
echo "text" | clip.exe
When running over SSH, use OSC 52 to write to the local clipboard:
echo "text" | printf '\e]52;c;%s\a' "$(base64 | tr -d '\n')"