Dokumentation zum Setup für externen Zugriff auf Docker-Container über Caddy als Reverse Proxy, mit Wildcard-DNS bei Hostinger.

Architektur-Übersicht

Internet
   │
   ▼
Router (Port-Forwarding 80→8880, 443→8443)
   │
   ▼
Caddy-Container (hört auf 8880/8443, intern 80/443)
   │  (Docker-Netzwerk: caddy-net)
   ├── papra:1221
   ├── grimmory:PORT
   └── ...weitere Dienste

Caddy ist der einzige Container mit Port-Freigabe nach außen. Alle Anwendungscontainer bleiben ohne eigenes externes Port-Mapping und sind nur über Caddy erreichbar (sofern kein zusätzliches ports:-Mapping für internen Zugriff besteht, siehe unten).

Komponenten

1. DDNS-Container (ddns-hostinger)

  • Pfad: /volume2/docker-compose/webserver/ddns-hostinger/docker-compose.yml
  • Script: /volume2/appdata/ddns-hostinger/update-dns.sh
  • Läuft alle 5 Minuten, prüft öffentliche IP über ifconfig.me
  • Bei Änderung: holt aktuelle DNS-Zone von Hostinger per API (GET), ersetzt/ergänzt nur den Wildcard-A-Record (*), sendet komplette Zone zurück (PUT mit overwrite: true)
  • Wichtig: Alle anderen Records (MX, TXT/SPF, CNAME, root-A) werden dabei unangetastet mitgeschickt – sonst würden sie bei jedem Update gelöscht
  • Cache der letzten IP: /volume2/appdata/ddns-hostinger/data/last_ip.txt
  • Container braucht curl und jq (wird beim Start per apk add installiert, siehe entrypoint in der compose)

Aktueller Inhalt update-dns.sh:

#!/bin/sh
set -e
API_TOKEN="<TOKEN>"
DOMAIN="ddung.de"
SUBDOMAIN="*"
CACHE_FILE="/data/last_ip.txt"
 
CURRENT_IP=$(curl -s https://ifconfig.me)
LAST_IP=$(cat "$CACHE_FILE" 2>/dev/null || echo "")
 
if [ "$CURRENT_IP" = "$LAST_IP" ]; then
  echo "$(date): IP unverändert ($CURRENT_IP), kein Update nötig."
  exit 0
fi
 
echo "$(date): IP geändert: $LAST_IP -> $CURRENT_IP. Hole aktuelle Zone..."
CURRENT_ZONE=$(curl -s -X GET "https://developers.hostinger.com/api/dns/v1/zones/${DOMAIN}" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json")
 
NEW_ZONE=$(echo "$CURRENT_ZONE" | jq \
  --arg sub "$SUBDOMAIN" \
  --arg ip "$CURRENT_IP" \
  '[.[] | select(.type != "A" or (.name != $sub and .name != "docs"))] + [{"type":"A","name":$sub,"ttl":300,"records":[{"content":$ip}]}]')
 
echo "$(date): Update Hostinger DNS..."
curl -s -X PUT "https://developers.hostinger.com/api/dns/v1/zones/${DOMAIN}" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"overwrite\": true, \"zone\": $NEW_ZONE}"
 
echo "$CURRENT_IP" > "$CACHE_FILE"
echo "$(date): Update fertig."

2. Caddy Reverse Proxy

  • Pfad: /volume2/docker-compose/caddy/docker-compose.yml
  • Caddyfile: /volume2/appdata/caddy/Caddyfile
  • Zertifikate/Config (persistent): /volume2/appdata/caddy/data, /volume2/appdata/caddy/config
  • Holt automatisch Let’s-Encrypt-Zertifikate per HTTP-01-Challenge für jede Domain in der Caddyfile

docker-compose.yml:

services:
  caddy:
    image: caddy:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - "8880:80"
      - "8443:443"
    volumes:
      - /volume2/appdata/caddy/Caddyfile:/etc/caddy/Caddyfile
      - /volume2/appdata/caddy/data:/data
      - /volume2/appdata/caddy/config:/config
    networks:
      - caddy-net
 
networks:
  caddy-net:
    external: true

Caddyfile (Beispiel mit Papra):

papra.ddung.de {
    reverse_proxy papra:1221
}

3. Docker-Netzwerk caddy-net

Gemeinsames externes Netzwerk, in dem Caddy und alle extern erreichbaren Anwendungscontainer hängen, damit Caddy sie über den Container-Namen ansprechen kann.

docker network create caddy-net

4. Router / Port-Forwarding

ExternIntern (NAS)
808880
4438443

Diese Ports werden vom Router auf die NAS weitergeleitet, dort mappt Caddy sie im Container auf 80/443.

DNS bei Hostinger

  • Wildcard-A-Record * → aktuelle öffentliche IP (automatisch von ddns-hostinger gepflegt)
  • Root-A-Record @ → separate, feste IP (nicht von DDNS betroffen)
  • MX, TXT (SPF, apple-domain), CNAME (www, sig1._domainkey) → unverändert, iCloud-Mail-bezogen

Jede Subdomain (papra.ddung.de, grimmory.ddung.de, etc.) löst dank Wildcard automatisch auf, ohne dass bei neuen Diensten ein neuer DNS-Eintrag nötig ist.

Neuen Dienst extern erreichbar machen

Wiederholbarer Ablauf für jeden weiteren Container:

  1. Container ins caddy-net hängen – in dessen docker-compose.yml:
networks:
  - default
  - caddy-net
 
networks:
  default:
  caddy-net:
    external: true

Danach: docker compose up -d im jeweiligen Verzeichnis.

  1. Neuen Block in der Caddyfile ergänzen:
neuerdienst.ddung.de {
    reverse_proxy <container-name>:<port>
}
  1. Caddy neu laden (ohne Neustart):
docker exec caddy caddy reload --config /etc/caddy/Caddyfile

Kein DNS-Eintrag nötig – der Wildcard-Record deckt das automatisch ab.

Papra – Sonderfall interner + externer Zugriff

Papra ist sowohl extern (https://papra.ddung.de über Caddy) als auch intern (http://192.168.2.200:1221 direkt) erreichbar, da die ports: - 1221:1221-Zeile in der Papra-compose erhalten bleibt.

services:
  papra:
    image: ghcr.io/papra-hq/papra:latest
    container_name: papra
    restart: unless-stopped
    ports:
      - 1221:1221
    environment:
      - AUTH_SECRET=<ROTIERT, NICHT HIER EINTRAGEN>
      - APP_BASE_URL=https://papra.ddung.de
      - INGESTION_FOLDER_IS_ENABLED=true
    volumes:
      - /volume2/appdata/papra/app/app-data:/app/app-data
      - /volume1/documents/papra-consume:/app/ingestion
    user: 1000:10
    networks:
      - default
      - caddy-net
 
networks:
  default:
  caddy-net:
    external: true

Bekanntes Risiko: APP_BASE_URL ist auf die externe Domain gesetzt. Falls das bei internem IP-Zugriff zu Cookie-/Login-Problemen führt, zwei Optionen:

  • Lokale DNS-Auflösung einrichten, sodass papra.ddung.de auch intern auf die NAS-IP zeigt (z.B. Pi-hole oder hosts-Datei)
  • ports:-Zeile aus Papra-compose entfernen, um Papra ausschließlich über Caddy erreichbar zu machen (dann aber vorher lokale DNS-Lösung sicherstellen, sonst kein interner Zugriff mehr)

Sicherheitshinweise

  • Nicht jeder Dienst sollte öffentlich erreichbar sein. Bei sensiblen Diensten (Home Assistant, Backup-Tools) lieber über VPN (z.B. WireGuard) statt direktem Caddy-Exposing erwägen.
  • API-Token und Secrets (Hostinger API-Token, AUTH_SECRET von Papra) regelmäßig rotieren, falls sie versehentlich geteilt wurden.
  • Bei extern erreichbaren Diensten auf starke Passwörter/2FA achten, ggf. fail2ban oder Crowdsec vor Caddy erwägen.

Troubleshooting-Hinweise (aus diesem Setup gelernt)

  • jq fehlt im DDNS-Container: In der entrypoint-Zeile der compose apk add --no-cache curl jq statt nur curl, danach docker compose up -d --force-recreate.
  • DDNS-Script macht nichts beim Testen: Cache-Datei data/last_ip.txt löschen, um ein Update zu erzwingen, auch wenn sich die IP nicht geändert hat.
  • DNS-Auflösung prüfen: nslookup <subdomain>.ddung.de – sollte auf die öffentliche IP zeigen (TTL 300s, also kurze Wartezeit bei Änderungen einplanen).
  • Netzwerk-Kontrolle: docker network inspect caddy-net zeigt, welche Container tatsächlich im Netzwerk hängen.