From 87f75ff210a0e2f582d829bc8df2a5b6dc2be50f Mon Sep 17 00:00:00 2001 From: Christian Gamsjaeger Date: Fri, 31 Jul 2026 18:03:26 +0200 Subject: [PATCH] fix(certs): add certbot renew hook --- CLAUDE.md | 65 +++++++++++++++++++++++++++++++++++++ Dockerfile | 13 -------- cron-restart-nginx | 2 -- deploy-hook/reload-nginx.sh | 25 ++++++++++++++ docker-compose.yml | 6 ++-- entrypoint.sh | 4 --- 6 files changed, 93 insertions(+), 22 deletions(-) create mode 100644 CLAUDE.md delete mode 100755 Dockerfile delete mode 100644 cron-restart-nginx create mode 100755 deploy-hook/reload-nginx.sh delete mode 100644 entrypoint.sh diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5792fb3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# 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`. diff --git a/Dockerfile b/Dockerfile deleted file mode 100755 index d7c9a09..0000000 --- a/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM nginx:latest -RUN apt-get update && apt-get install -y cron && apt-get clean - -COPY cron-restart-nginx /etc/cron.d/cron-restart-nginx - -RUN chmod 0644 /etc/cron.d/cron-restart-nginx - -RUN crontab /etc/cron.d/cron-restart-nginx - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/cron-restart-nginx b/cron-restart-nginx deleted file mode 100644 index 59b2506..0000000 --- a/cron-restart-nginx +++ /dev/null @@ -1,2 +0,0 @@ -0 0 1 * * nginx -s reload - diff --git a/deploy-hook/reload-nginx.sh b/deploy-hook/reload-nginx.sh new file mode 100755 index 0000000..06694e0 --- /dev/null +++ b/deploy-hook/reload-nginx.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# certbot deploy hook — reloads the dockerized nginx after a certificate renewal. +# +# Runs on the HOST as root, and only when certbot actually installed a new +# certificate. Install by symlinking it into certbot's deploy hook directory: +# +# sudo ln -s "$PWD/deploy-hook/reload-nginx.sh" \ +# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +# +# nginx caches certificates in memory, so without this the renewed cert on +# disk is never served. + +set -euo pipefail + +CONTAINER=nginx-proxy + +if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" != "true" ]; then + echo "deploy-hook: container '$CONTAINER' is not running — nginx NOT reloaded" >&2 + exit 1 +fi + +# SIGHUP makes the nginx master re-read its config and re-open cert files. +# Relies on nginx being PID 1 in the container. +docker kill -s HUP "$CONTAINER" +echo "deploy-hook: reloaded nginx in '$CONTAINER' for ${RENEWED_DOMAINS:-unknown domains}" diff --git a/docker-compose.yml b/docker-compose.yml index 5c62515..65a2609 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ services: nginx: - build: - context: . - dockerfile: Dockerfile + image: nginx:latest + # Stable name so the host-side certbot deploy hook can target the container. + container_name: nginx-proxy volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./sites-available:/etc/nginx/sites-available diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index b22ff5f..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -service cron start - -nginx -g "daemon off;"