Skip to content

Reverse proxy architecture — note

Machine translation of the French original.

The complete path

flowchart TB
    subgraph internet["Internet"]
        client["Client<br/>navigateur"]
    end

    subgraph ovh["OVH"]
        dns["Zone DNS savethemall.eu<br/>A → <IP publique>"]
    end

    subgraph maison["LAN <sous-reseau LAN>"]
        velop["Routeur Velop<br/>NAT 443 → nas:8080"]

        subgraph nas["NAS Synology · le NAS"]
            nascaddy["nas-caddy — EDGE WAN<br/>TLS explicite, certs copiés"]
            statique["Sites statiques locaux<br/>weber · chroniques · rgpd"]
        end

        subgraph vmd["vm-docker · vm-docker"]
            caddy["Caddy<br/>routage interne + CrowdSec"]
            authentik["Authentik<br/>outpost embarqué"]
            app["Conteneur applicatif<br/>infrastructure_network"]
        end
    end

    client -->|"résout le nom"| dns
    client -->|"HTTPS 443"| velop
    velop --> nascaddy
    nascaddy -->|"local"| statique
    nascaddy -->|"import to_vmdocker<br/>header_up Host"| caddy
    caddy -->|"forward_auth"| authentik
    authentik -.->|"302 vers auth.savethemall.eu<br/>si pas de session"| client
    caddy -->|"reverse_proxy nom:port"| app

Why two layers

One would have been sufficient functionally. There are two because the two machines cannot do the same thing:

  • The NAS is the only one that sees the WAN. The NAT of the router points to it. It therefore hosts the static sites that must survive the death of vm-docker.
  • vm-docker is the only one that can block. The NAS runs on a DSM kernel without xt_set, without a /lib/modules, and sudo, and requires a password: no L3 blocking is possible. The CrowdSec remediation lives on vm-docker, in an application-level 403.

The two consequences that cost hours

A L3 filter placed on vm-docker does nothing

All WAN requests arrive at vm-docker with nas as the source. A rule per client IP is structurally blind to it.

What works: trusted_proxies static le NAS/32 in the global options of Caddy, which makes it read the X-Forwarded-For from nas-caddy. The modules that consume the client IP — including CrowdSec — then see the real one.

The static sites served by the NAS do not pass through CrowdSec

They never pass through the vm-docker Caddy. The L7 remediation does not cover them. This is an assumed limitation, not an oversight.

Authentication

The snippet shared, in the vm-docker Caddyfile:

(authentik) {
    forward_auth authentik-server:9000 {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-authentik-username X-authentik-groups X-authentik-email X-authentik-name
    }
}

And its equivalent at the true edge, for the static sites served locally by the NAS:

(authentik_edge) {
    forward_auth vm-docker:9092 {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-authentik-username X-authentik-groups X-authentik-email X-authentik-name
    }
}

What forward_auth does, request by request

sequenceDiagram
    autonumber
    participant N as Navigateur
    participant C as Caddy
    participant A as Authentik
    participant S as Service

    N->>C: GET /une-page
    C->>A: sous-requête /outpost.goauthentik.io/auth/caddy
    alt pas de session
        A-->>C: 401
        C-->>N: 302 vers auth.savethemall.eu
        N->>A: connexion
        A-->>N: 302 vers /outpost.goauthentik.io/callback
        Note over N,C: c'est CETTE route qui doit exister<br/>dans un `handle` frère, sinon le callback<br/>tombe dans le reverse_proxy de l'app
        N->>C: GET /outpost.goauthentik.io/callback
        C->>A: relais vers authentik-server:9000
        A-->>N: cookie de session, puis 302 vers /une-page
        N->>C: GET /une-page (avec le cookie)
    end
    C->>A: sous-requête d'autorisation
    A-->>C: 200 + en-têtes X-authentik-*
    C->>S: reverse_proxy, en-têtes recopiés
    S-->>N: la page

The service never sees the password: it receives a request that is already authorized, with the identity in the headers. This is also its limitation — it has no way of knowing that a session has ended.

forward_auth happens before handle

L'ordre d'exécution des directives Caddy n'est pas l'ordre du fichier. forward_auth est ordonné avant handle et reverse_proxy : il attrape la requête en premier, quoi qu'on écrive après lui — dans le même bloc comme dans un snippet importé.

flowchart TB
    req["Requête<br/>/outpost.goauthentik.io/callback"]

    subgraph fichier["Ce que le fichier donne à lire"]
        f1["1. handle /outpost…/*"]
        f2["2. forward_auth"]
        f3["3. reverse_proxy app"]
    end

    subgraph reel["L'ordre réellement appliqué par Caddy"]
        r1["1. forward_auth"]
        r2["2. handle"]
        r3["3. reverse_proxy"]
    end

    req --> fichier
    fichier -.->|"n'est PAS<br/>l'ordre d'exécution"| reel
    r1 --> perdu["forward_auth intercepte<br/>→ 302, boucle de connexion"]

    classDef bad stroke-width:3px,stroke-dasharray: 5 5
    class perdu bad

Conséquence : la route de l'outpost ne peut pas être posée une fois pour toutes dans le snippet (authentik). Les deux écritures suivantes ont été essayées, elles pass caddy validate, recharge without errors, and do not change anything:

(authentik) {
    handle /outpost.goauthentik.io/* { reverse_proxy authentik-server:9000 }   # sans effet
    reverse_proxy /outpost.goauthentik.io/* authentik-server:9000              # sans effet
    forward_auth authentik-server:9000 { ... }
}

The only place where it works is the site, with the app also working in handle: two handle routes are mutually exclusive, and the more specific one wins before forward_auth comes into play.

site.savethemall.eu {
    import tls_cert site.savethemall.eu
    handle /outpost.goauthentik.io/* {
        reverse_proxy authentik-server:9000
    }
    handle {
        import authentik
        reverse_proxy monservice:8080
    }
}

The probe that distinguishes the two forms in a single request, without requiring an account or session:

curl -s -o /dev/null -w '%{http_code}\n' https://<site>/outpost.goauthentik.io/zzz
# 404 → la route outpost existe, c'est Authentik qui répond
# 302 → pas de route ; forward_auth a intercepté

This is not a failure for sites that don't have it

Out of 39 sites that import authentik, 4 use this handle. The other 35 function normally. The handle is a conformant hardening according to the Authentik documentation, not a requirement for operation. Generalizing the form would require rewriting 35 blocks individually — a cold rewrite, with the probe as the criterion, not a sed.

OIDC first, forward_auth second, never both

For a new service, first look for integrations.goauthentik.io/documentation/<service>. If the application supports OIDC, connect it.

forward_auth does not know the application's identity: no account, no role, no proper logout — just a wall. It remains the best choice for those who do not have user awareness, such as a static site.

Having both together is a trap

The proxy intercepts /api/auth/callback/* before the application → a connection loop, with three components that all appear to be working correctly.

Close the local connection (DISABLE_PASSWORD_AUTH) after the first successful OIDC connection, never before.

Certificates

  • Issued by acme.sh using DNS-01 at OVH, one certificate per domain, in /opt/docker/acme/certs/<fqdn>/.
  • Explicitly loaded: auto_https off in both Caddy instances, and a macro (tls_cert) per site.
  • Reloaded after renewal by the Caddy administration API (POST http://127.0.0.1:2019/load), only on the local loop.
  • The NAS emits nothing: the certificates are copied to it daily rsync from vm-docker.

A detail that bites: an upstream designated by its IP

reverse_proxy https://le NAS

Caddy then sends Host: nas:443. Most applications respond with absolute redirects based on this header, and the client connects to an internal address. It is also important to add:

reverse_proxy https://le NAS {
    header_up Host {host}
}

Dernière vérification de cette page : 2026-09-08