nginx-docker/CLAUDE.md
2026-07-31 18:03:26 +02:00

5.6 KiB

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).

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/<name>) 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 execs 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 # <hostname> 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.