# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Do not run this here This repo is edited on this machine but deployed on a different host — the actual server. Never run `docker compose up`/`build`/`exec` (or any other command that starts this stack) on this system as-is. It would bind port 443, expect `/etc/letsencrypt/live/gaems.at/` and the external `vaultwarden-network`/`forgejo-network`/`nextcloud-network` to exist locally, and none of that holds here. Validation and reloads happen on the deployment host; the commands below document that workflow, they are not for local execution. ## What this is A containerized nginx acting as the TLS-terminating reverse proxy in front of self-hosted services on the `gaems.at` domain (Vaultwarden, Forgejo, Nextcloud). There is no application code and no custom image — the repo is nginx configuration bind-mounted into stock `nginx:latest`, plus one host-side certbot hook. ## Commands Run on the deployment host, not here (see above). ```bash docker compose up -d # stock image, nothing to build # Validate config before applying it — always do this after editing nginx.conf or a site file docker compose exec nginx nginx -t # Apply config changes without dropping connections (configs are bind-mounted) docker compose exec nginx nginx -s reload docker compose logs -f nginx tail -f log/error.log log/access.log # bind-mounted from /var/log/nginx, gitignored # Install the certbot deploy hook (once per host, as root) sudo ln -s "$PWD/deploy-hook/reload-nginx.sh" /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh # Exercise the hook without waiting for a renewal sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh # Compare the cert actually being served against the one on disk echo | openssl s_client -connect vault.gaems.at:443 -servername vault.gaems.at 2>/dev/null \ | openssl x509 -noout -dates sudo openssl x509 -in /etc/letsencrypt/live/gaems.at/fullchain.pem -noout -dates ``` ## Architecture **Config is bind-mounted, not baked in.** `nginx.conf`, `sites-available/`, and `sites-enabled/` are mounted into the container by `docker-compose.yml`. Editing a site file on the host and running `nginx -s reload` is the entire deploy loop. There is deliberately no Dockerfile: everything this setup needs is configuration or lives on the host, so don't reintroduce a build step to add a scheduled task or a helper binary inside the container. **Debian-style site layout on the official nginx image.** The upstream `nginx:latest` image has no `sites-enabled` convention; `nginx.conf` reintroduces it via `include /etc/nginx/sites-enabled/*`. Enabling a site means creating a **relative** symlink in `sites-enabled/` (`../sites-available/`) so it resolves identically on host and in the container. `sites-available/nextcloud.gaems.at` currently exists but is *not* enabled. **Upstreams are resolved by Docker DNS over external networks.** `proxy_pass http://forgejo:3000` works only because the nginx container joins the `forgejo-network` external network, which is created and owned by that service's own compose project. Adding a new backend requires three coordinated changes: a site file in `sites-available/`, a symlink in `sites-enabled/`, and the backend's network added to both `networks:` blocks in `docker-compose.yml`. The networks must already exist (`docker network ls`) or `up` fails. **TLS, and the reload that makes it work.** A single Let's Encrypt certificate for `gaems.at` (`/etc/letsencrypt/live/gaems.at/`) covers every vhost; the host's `/etc/letsencrypt` is mounted read-only. Certbot runs on the host, outside this repo. nginx reads certificates once and caches them in memory, so **a renewed cert on disk is not served until nginx reloads.** That reload is `deploy-hook/reload-nginx.sh`, a certbot deploy hook that runs on the host after a successful renewal and sends SIGHUP to the container. Three things it depends on, all easy to break: - It must be symlinked into `/etc/letsencrypt/renewal-hooks/deploy/`. **The repo file alone does nothing** — an uninstalled hook fails exactly like no hook at all, silently, until the cert expires. - It targets `container_name: nginx-proxy` from `docker-compose.yml`. Renaming the container breaks the hook. - `docker kill -s HUP` signals PID 1, which is nginx only because the stock image `exec`s it. Adding a wrapper entrypoint that doesn't `exec` would send the signal to the wrapper instead. This replaced an in-container cron reload that never once ran: cron executes jobs with `PATH=/usr/bin:/bin`, which excludes `/usr/sbin/nginx`, and the image has no syslog daemon or MTA, so every failure was discarded. The lesson generalizes — **anything scheduled inside this container fails invisibly.** Renewal-triggered work belongs on the host. **Only 443 is published.** `docker-compose.yml` maps `443:443` only, so the `listen 80` HTTP→HTTPS redirect in `sites-available/default` is unreachable from outside the container. Publish `80:80` if that redirect should actually work. **Default vhost catches unmatched traffic**: `default_server` on 443 returns 404, so an unrouted subdomain fails closed rather than hitting an arbitrary backend. ## Conventions Site files follow one shape — copy an existing one rather than writing fresh. Tabs for indentation, a `# ` comment header, and the filename matches the `server_name`. Every proxied location sets `Host`, `X-Real-IP`, and `X-Forwarded-Proto` at minimum; per-backend tuning (e.g. Nextcloud's `client_max_body_size` and gzip block) lives inside that site's `location`, not in the global `nginx.conf`.