fix(certs): add certbot renew hook

This commit is contained in:
Christian Gamsjaeger 2026-07-31 18:03:26 +02:00
parent 8d97108633
commit 87f75ff210
6 changed files with 93 additions and 22 deletions

65
CLAUDE.md Normal file
View file

@ -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/<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 `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 `# <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`.

View file

@ -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"]

View file

@ -1,2 +0,0 @@
0 0 1 * * nginx -s reload

25
deploy-hook/reload-nginx.sh Executable file
View file

@ -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}"

View file

@ -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

View file

@ -1,4 +0,0 @@
#!/bin/bash
service cron start
nginx -g "daemon off;"