Du betreibst nginx mit Let’s Encrypt-Zertifikaten. Alles läuft. Bis du bemerkst, dass certbot renew bei jeder Ausführung nginx stoppt und neu startet — für jedes einzelne Zertifikat.

Bei einem Zertifikat: egal. Bei 42: addiert sich zu einer Downtime-Fenster, die Nutzer bemerken.

Der Fix heißt webroot-Modus. Er lässt certbot die ACME-Challenge über nginx ausliefern, ohne nginx anfassen zu müssen. Migration dauert 15 Minuten. Dann gibt es einen Gotcha, der bei einem von 42 Zertifikaten still fehlschlägt.

TL;DR

  1. nginx-Snippet für ACME anlegen und in alle Server-Blöcke einbinden
  2. Renewal-Configs auf authenticator = webroot umstellen
  3. Systemd-Timer-Override anpassen: pre/post-hook → deploy-hook
  4. Bei Server-Blöcken mit return 301 auf Root-Level: Redirect in location / wrappen, sonst frisst der Rewrite-Phase die Challenge weg

Das Problem: standalone stoppt nginx

certbot im standalone-Modus bindet selbst Port 80, um die ACME-Challenge zu beantworten. Dafür muss nginx vorher runter.

Das sieht im Systemd-Timer-Override so aus:

[Service]
ExecStart=
ExecStart=/usr/bin/certbot renew \
  --pre-hook "systemctl stop nginx" \
  --post-hook "systemctl start nginx"

Bei einer Handvoll Zertifikate ist die Gesamtpause kurz. Bei 42 Zertifikaten — jedes mit eigenem pre/post-hook-Zyklus — summiert sich das. nginx ist mehrfach für mehrere Sekunden weg.

Die Lösung: webroot-Modus

Im webroot-Modus läuft nginx durch. certbot legt die Challenge-Datei in ein Verzeichnis, nginx liefert sie aus, Let’s Encrypt prüft sie, Zertifikat wird erneuert. Kein Stopp, kein Neustart.

Schritt 1: ACME-Verzeichnis und nginx-Snippet

mkdir -p /var/www/acme

Dann das Snippet /etc/nginx/snippets/letsencrypt.conf:

location ^~ /.well-known/acme-challenge/ {
    root /var/www/acme;
    default_type "text/plain";
}

^~ ist wichtig: es hat Vorrang vor regulären Ausdrücken. Die Challenge-Requests kommen durch, egal welche anderen Location-Regeln im Server-Block stehen.

Schritt 2: Snippet in alle nginx-Sites einbinden

In jeden Server-Block, der über Port 80 erreichbar ist:

server {
    listen 80;
    server_name <DEIN-HOST>.<DEINE-DOMAIN>;

    include snippets/letsencrypt.conf;

    # ... Rest des Server-Blocks
}

Alternativ: den Location-Block direkt einfügen statt include. Snippet ist aber wartbarer wenn du 42 Sites hast.

Schritt 3: Renewal-Configs umstellen

In /etc/letsencrypt/renewal/ liegt für jedes Zertifikat eine .conf-Datei. Darin steht bisher:

authenticator = standalone

Das muss auf webroot:

authenticator = webroot
webroot_path = /var/www/acme

Für eine Handvoll Zertifikate: manuell. Für 42:

cd /etc/letsencrypt/renewal

# Backup zuerst
cp -r . ../renewal.bak

# standalone → webroot ersetzen
sed -i 's/^authenticator = standalone/authenticator = webroot/' *.conf

# webroot_path hinzufügen wo noch nicht vorhanden
for f in *.conf; do
    grep -q 'webroot_path' "$f" || \
        sed -i '/^authenticator = webroot/a webroot_path = /var/www/acme' "$f"
done

Danach kurz prüfen ob alles sauber aussieht:

grep -A1 'authenticator' /etc/letsencrypt/renewal/*.conf | head -30

Schritt 4: Systemd-Timer-Override anpassen

Der Override liegt üblicherweise unter /etc/systemd/system/certbot.service.d/override.conf oder ähnlich. Der pre/post-hook fliegt raus:

[Service]
ExecStart=
ExecStart=/usr/bin/certbot renew \
  --deploy-hook "systemctl reload nginx"

--deploy-hook läuft nur wenn ein Zertifikat tatsächlich erneuert wurde — nicht bei jedem Renewal-Lauf. reload statt restart reicht: nginx liest die neuen Zertifikatsdateien ohne Verbindungen zu kappen.

systemctl daemon-reload

Der Gotcha: return 301 auf Server-Ebene

41 von 42 Zertifikaten haben beim ersten certbot renew --dry-run problemlos funktioniert. Eines nicht.

Der fehlende Server-Block sah so aus:

server {
    listen 192.168.66.1:80;
    server_name mail.<DEINE-DOMAIN>;
    return 301 https://webmail.<DEINE-DOMAIN>$request_uri;
}

Ein simpler HTTP-zu-HTTPS-Redirect. Dazu kam das Snippet per include — also war ein location /.well-known/acme-challenge/ Block vorhanden. Trotzdem: Challenge-Request → 301 → https://webmail.<DEINE-DOMAIN>/.well-known/acme-challenge/... → 404.

Warum das passiert

nginx’s return-Direktive auf Server-Block-Ebene gehört zur Rewrite-Phase. Die Rewrite-Phase läuft vor dem Location-Matching. Das bedeutet: nginx sieht den Request, führt return 301 aus, und schaut sich die Location-Blöcke gar nicht erst an.

Ein location /.well-known/acme-challenge/ Block in demselben Server-Block nützt nichts, solange return 301 außerhalb eines Location-Blocks steht. Der Redirect feuert zuerst — für jeden Request, ausnahmslos.

Der Fix: Redirect in location / wrappen

server {
    listen 192.168.66.1:80;
    server_name mail.<DEINE-DOMAIN>;

    location /.well-known/acme-challenge/ {
        root /var/www/acme;
        default_type "text/plain";
    }

    location / {
        return 301 https://webmail.<DEINE-DOMAIN>$request_uri;
    }
}

Jetzt läuft das Location-Matching normal durch. /.well-known/acme-challenge/ trifft zuerst (durch ^~ im Snippet oder durch spezifischeren Prefix-Match), normale Requests landen in location / und werden redirected.

Das gilt für alle Server-Blöcke die return auf Top-Level verwenden — egal ob Redirect zu HTTPS oder zu einer anderen Domain.

Verifikation

Erst trocken laufen lassen:

certbot renew --dry-run

Wenn einer fehlschlägt, siehst du in der Ausgabe welches Zertifikat und welche Domain. Dann den entsprechenden Server-Block prüfen: return auf Root-Ebene?

Für einen schnellen Check aller Renewal-Configs:

# Alle Domains aus den Renewal-Configs
grep -h '^domains' /etc/letsencrypt/renewal/*.conf

Dann für jeden hostname prüfen ob der zugehörige nginx-Server-Block ein Top-Level return hat:

grep -r 'return 3' /etc/nginx/sites-enabled/

Alles was dort auftaucht: Server-Block öffnen, return in location / wrappen.

Nach erfolgreichem dry-run, echter Lauf (falls die Zertifikate bald ablaufen) oder einfach warten bis der Timer das nächste Mal anspringt.

Fazit

Webroot ist der richtige Modus sobald nginx dauerhaft läuft. standalone macht nur Sinn wenn kein Webserver läuft — oder wenn man es beim ersten Setup schnell hinter sich bringen will.

Die zwei Lernpunkte:

  1. return auf Server-Ebene ignoriert Location-Blöcke. Das ist kein Bug, das ist nginx-Architektur. Rewrite-Phase vor Location-Phase. Wer redirectet, muss es in location / tun wenn er Ausnahmen haben will.

  2. Dry-run immer vor dem ersten echten Lauf. 41/42 wäre nicht aufgefallen bis das 42. Zertifikat abläuft — und dann nachts um 3 Uhr.

certbot renew --dry-run
# Alles grün? Dann ist's gut.