fix(certs): add certbot renew hook
This commit is contained in:
parent
8d97108633
commit
87f75ff210
6 changed files with 93 additions and 22 deletions
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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`.
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -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"]
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
0 0 1 * * nginx -s reload
|
|
||||||
|
|
||||||
25
deploy-hook/reload-nginx.sh
Executable file
25
deploy-hook/reload-nginx.sh
Executable 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}"
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
image: nginx:latest
|
||||||
context: .
|
# Stable name so the host-side certbot deploy hook can target the container.
|
||||||
dockerfile: Dockerfile
|
container_name: nginx-proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
- ./sites-available:/etc/nginx/sites-available
|
- ./sites-available:/etc/nginx/sites-available
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
service cron start
|
|
||||||
|
|
||||||
nginx -g "daemon off;"
|
|
||||||
Loading…
Reference in a new issue