Oder: Wie Docker NAT deine Client-IPs verschluckt und was du dagegen tun kannst

## Das Symptom: Eine IP, sie alle zu knechten

Es begann harmlos. Ein Audit-Log sollte die IP-Adresse des Unterzeichners zeigen. Stattdessen: `172.19.0.1`. Immer. Bei jedem. Egal ob der Kunde aus Berlin, München oder vom Mars kam.

„`
IP: 172.19.0.1
IP: 172.19.0.1
IP: 172.19.0.1
„`

Das ist keine echte Client-IP. Das ist die Docker-Gateway-Adresse. Das ist, als würde dein Postbote bei jeder Lieferung „Absender: Dein Briefkasten“ draufschreiben.

## Die Architektur des Scheiterns

Mein Setup war klassisch:

„`
Internet → Traefik (Port 443) → Docker-Netzwerk → App-Container
„`

Traefik lauscht auf den Host-Ports, terminiert TLS, und leitet an die Container weiter. Alles über `docker-compose.yml`, alles schön mit Labels konfiguriert. Was soll schon schiefgehen?

## Versuch 1: Die App ist schuld! (Spoiler: Nein)

Erste Vermutung: Die Rails-App (DocuSeal) vertraut dem `X-Forwarded-For`-Header nicht. Also:

„`yaml
environment:
TRUSTED_PROXIES: „172.16.0.0/12,10.0.0.0/8,192.168.0.0/16“
„`

**Ergebnis:** Nichts. DocuSeal ignorierte die Variable, weil sie nicht nativ unterstützt wird.

## Versuch 2: Rails-Initializer! (Spoiler: Auch nein)

Okay, dann eben ein Custom-Initializer:

„`ruby
# config/initializers/trusted_proxies.rb
Rails.application.config.action_dispatch.trusted_proxies =
ActionDispatch::RemoteIp::TRUSTED_PROXIES + [
IPAddr.new(‚172.16.0.0/12‘),
IPAddr.new(‚10.0.0.0/8‘),
IPAddr.new(‚192.168.0.0/16‘)
]
„`

Container neu gestartet, Initializer wurde geladen, Test gemacht…

**Ergebnis:** Immer noch `172.19.0.1`.

An diesem Punkt fängt man an, an seinem Verstand zu zweifeln.

## Die Erleuchtung: Es ist nicht die App, du Horst

Ein Blick in die Traefik-Logs (nachdem ich `–accessLog=true` aktiviert hatten):

„`json
{„ClientHost“:“172.19.0.1″, „RequestHost“:“app.example.com“, …}
„`

Moment. **Traefik selbst** sieht nur `172.19.0.1` als Client? Das bedeutet…

## Das eigentliche Problem: Docker’s kleines schmutziges Geheimnis

Docker hat einen sogenannten **userland-proxy** (auch bekannt als `docker-proxy`). Wenn du Ports mappst (`-p 443:443`), passiert Folgendes:

1. Ein Paket kommt von `83.123.45.67:54321` an Port 443
2. Docker’s userland-proxy nimmt das Paket entgegen
3. Der Proxy leitet es an den Container weiter – **als wäre er selbst der Absender**
4. Der Container sieht: Quelle = `172.19.0.1` (Docker-Gateway)

Die echte Client-IP? Weg. Verschluckt. Gone.

„`
Was passiert:
Client ──→ Host:443 ──→ docker-proxy ──→ Container
(83.x.x.x) (wird zu 172.19.0.1)

Was wir wollen:
Client ──→ Host:443 ──→ iptables DNAT ──→ Container
(83.x.x.x) (IP bleibt erhalten!)
„`

## Die Lösung: userland-proxy abschalten

Docker kann statt dem userland-proxy auch reines iptables-NAT verwenden. Dabei bleibt die Quell-IP erhalten:

„`bash
# /etc/docker/daemon.json erstellen
sudo bash -c ‚echo „{\“userland-proxy\“: false}“ > /etc/docker/daemon.json‘

# Docker neu starten (ACHTUNG: kurze Downtime!)
sudo systemctl restart docker
„`

Nach dem Neustart:

„`json
{„ClientHost“:“83.123.45.67″, „RequestHost“:“app.example.com“, …}
„`

**Die echte IP!** Halleluja!

## Warum existiert der userland-proxy überhaupt?

Gute Frage. Der userland-proxy löst ein paar Edge Cases:

1. **Hairpin NAT**: Container, die sich selbst über die Host-IP erreichen wollen
2. **Ältere Kernel**: Wo iptables-NAT Probleme machte
3. **Nicht-Linux-Systeme**: Docker Desktop auf Mac/Windows

Für einen normalen Linux-Server mit halbwegs aktuellem Kernel? Brauchst du nicht.

## Bonus: Was ich sonst noch gelernt habe

### Timezone in Alpine-Containern

`TZ=Europe/Berlin` allein reicht nicht! Alpine hat keine tzdata installiert. Lösung:

„`yaml
volumes:
– /etc/localtime:/etc/localtime:ro
– /usr/share/zoneinfo:/usr/share/zoneinfo:ro
„`

### Traefik Access-Log mit Rotation

Wenn du schon Logs machst, dann richtig:

„`yaml
traefik:
command:
– „–accessLog=true“
– „–accessLog.format=json“
logging:
driver: json-file
options:
max-size: „50m“
max-file: „5“
„`

## TL;DR

| Problem | Lösung |
|———|——–|
| Alle Client-IPs sind `172.x.x.x` | `{„userland-proxy“: false}` in `/etc/docker/daemon.json` |
| Traefik sieht falsche IP | Siehe oben – liegt an Docker, nicht Traefik |
| `TRUSTED_PROXIES` hilft nicht | Weil die echte IP gar nicht erst ankommt |
| Rails-Initializer hilft nicht | Siehe oben |
| Timezone falsch in Alpine | `/etc/localtime` mounten |

## Fazit

Manchmal ist das Problem nicht dort, wo man es vermutet. Ich habe deutlich zu viel Zeit damit verbracht, die App zu debuggen, obwohl Docker selbst der Übeltäter war.

Die Moral von der Geschichte: Wenn alle deine Besucher angeblich aus `172.19.0.1` kommen, dann lügt nicht deine App – dann lügt Docker.

*Getestet mit: Docker 24.x, Traefik 3.x, DocuSeal 2.x auf gemietetem VPS*

*Verfasst nach einer lehrreichen Debug-Session, die mit „das ist bestimmt schnell gefixt“ begann.*