TCP passthrough routing failing when falling back from QUIC to TCP?

Hello community,

maybe someone has an idea.

I have an strange issue: I have set up traefik for TLS passthrough to another traefik which is terminating TLS and then forwarding the traffic to a nextcloud container.

It works fine with macOS NextCloud app and any browser I have tested so far.

It does not work with the iOS NextCloud app, which complains about the certificate, because the first traefik (let’s call it “passthrough-traefik) is serving the default traefik cert instead of passing the traffic through, for some reason.

Disabling HTTP3 on traefik fixes the issue.

Additional questions, to improve my knowledge (different LLMs gave me contradicting answers on this...)

  1. Is it possible to reverse proxy QUIC traffic at all? (I think yes, but I can see the nextcloud iOS app falling back to TCP somehow)
  2. Is it possible to do SNI routing on QUIC traffic? (assuming no ECH)
  3. If the answer to 2/ is no, is it possible to route based on ClientIP for example, or is any kind of routing not possible anymore?

Back to the issue I have:

I have see the below in the passthrough-Traefik logs:

traefik-public | 2026-04-02T19:29:54Z DBG github.com/traefik/traefik/v3/pkg/tls/tlsmanager.go:288 > Serving default certificate for request: "my.sub.domain.com"

I captured a pcap of the traffic from the nextcloud iOS app and I see it does QUIC at first, and then sends the clientHello over TCP (SNI is not encrypted, I can see it in the pcap).

Question 4: is a fallback from QUIC to TCP expected with the below config or should it allow it through?

docker-compose.yml
services:
  traefik:
    image: traefik:v3
    container_name: traefik-public
    network_mode: host
    restart: unless-stopped

    # Security hardening
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW

    command:
      # Global settings
      - "--global.checkNewVersion=false"
      - "--global.sendAnonymousUsage=false"

      # Access logs for CrowdSec
      - "--accesslog=true"
      - "--accesslog.format=json"
      - "--accesslog.fields.headers.defaultmode=drop"
      - "--accesslog.fields.headers.names.X-Forwarded-For=keep"
      - "--accesslog.filepath=/var/log/traefik/access.log"


      # EntryPoints (Only 443 TCP and UDP)
      - "--entrypoints.https-IPv4.address=${VPS_PUBLIC_IPV4}:443"
      - "--entrypoints.https-IPv6.address=${VPS_PUBLIC_IPV6}:443"

      # HTTP/3 (QUIC) configuration
      - "--entrypoints.https-IPv4.http3=true"
      - "--entrypoints.https-IPv6.http3=true"

      # Providers
      - "--providers.docker=false"
      - "--providers.file.filename=/etc/traefik/config/dynamic.yml"
      - "--providers.file.watch=true"
      - "--providers.file.debugLogGeneratedTemplate=true"

      # API (Disabled for security)
      - "--api.dashboard=false"
      - "--api.insecure=false"

      # Log level
      - "--log.level=DEBUG"

    # must load .env file into Traefik's service environment
    # in order for dynamic.yml to use these variables in go templates.
    env_file:
      - .env

    volumes:
      # Dynamic configuration directory
      - ${DOCKER_PATH}/traefik/dynamic.yml:/etc/traefik/config/dynamic.yml:ro
      # for crowdsec
      - ${DOCKER_PATH}/traefik/logs:/var/log/traefik

dynamic config contains multiple probably irrelevant things, I didn't filter out for fear of removing something helpful:

dynamic.yml
tcp:
  routers:
    immich-ipv4-router:
      entryPoints: ["https-IPv4"]
      rule: "HostSNI(`{{ env "PUBLIC_IMMICH_DOMAIN" }}`) && ClientIP(`0.0.0.0/0`)"
      service: traefik-ipv4-service
      tls:
        passthrough: true

    immich-ipv6-router:
      entryPoints: ["https-IPv6"]
      rule: "HostSNI(`{{ env "PUBLIC_IMMICH_DOMAIN" }}`) && ClientIP(`::/0`)"
      service: traefik-ipv6-service
      tls:
        passthrough: true

    nextcloud-ipv4-router:
      entryPoints: ["https-IPv4"]
      rule: "HostSNI(`{{ env "PUBLIC_NEXTCLOUD_DOMAIN" }}`) && ClientIP(`0.0.0.0/0`)"
      service: traefik-ipv4-service
      tls:
        passthrough: true

    nextcloud-ipv6-router:
      entryPoints: ["https-IPv6"]
      rule: "HostSNI(`{{ env "PUBLIC_NEXTCLOUD_DOMAIN" }}`) && ClientIP(`::/0`)"
      service: traefik-ipv6-service
      tls:
        passthrough: true

    collabora-ipv4-router:
      entryPoints: ["https-IPv4"]
      rule: "HostSNI(`{{ env "COLLABORA_DOMAIN" }}`) && ClientIP(`0.0.0.0/0`)"
      service: traefik-ipv4-service
      tls:
        passthrough: true

    collabora-ipv6-router:
      entryPoints: ["https-IPv6"]
      rule: "HostSNI(`{{ env "COLLABORA_DOMAIN" }}`) && ClientIP(`::/0`)"
      service: traefik-ipv6-service
      tls:
        passthrough: true


  services:
    traefik-ipv4-service:
      loadBalancer:
        servers:
          - address: "{{ env "LOCAL_TRAEFIK_IPV4" }}:443"
        healthCheck:
          interval: "30s"
          timeout: "5s"

    traefik-ipv6-service:
      loadBalancer:
        servers:
          - address: "{{ env "LOCAL_TRAEFIK_IPV6" }}:443"
        healthCheck:
          interval: "30s"
          timeout: "5s"

Environment details:

  • Ubuntu 24
  • Docker 29
  • Traefik 3.6.9

I also found this, maybe related:

What are you trying to achieve? Why use TLS passthrough? The first Traefik instance needs to have access to the TLS cert, otherwise no HostSNI() routing is possible, except for HostSNI(`*`).

To answer bluepuma77's question as well: TLS passthrough with HostSNI() routing works without needing the cert on the first Traefik. It reads the SNI from the TLS ClientHello, which is unencrypted, and uses that to route the raw TCP stream to the right backend. No cert needed on the passthrough side for that.

The iOS issue is likely related to QUIC/HTTP3. The iOS Nextcloud app tries HTTP/3 (QUIC on UDP 443) before falling back to TCP. Your passthrough-traefik is set up for TCP passthrough only, so the QUIC attempt either fails silently or hits a different routing path. When the app falls back to TCP, something in that handshake sequence causes the passthrough-traefik to not match the HostSNI rule properly and serve its default cert instead.

Two things to check:

  1. Make sure the passthrough-traefik is not advertising HTTP/3 support. If the inner Traefik sets the Alt-Svc header in responses, those leak through the passthrough and tell clients to try QUIC. Strip that header on the passthrough-traefik with a headers middleware, or disable HTTP/3 on the inner Traefik entirely.

  2. Check your HostSNI rule on the passthrough TCP router. If it is set to HostSNI(\*`)` then any connection matches, including ones where the SNI parsing failed. Set it to the explicit domain name instead. That way if the SNI fails to parse (which can happen on QUIC fallback), the router won't match and you will see a clearer error in the logs.

If stripping Alt-Svc resolves the iOS issue, then the root cause was the app attempting QUIC where no QUIC passthrough was configured.

1 Like

Thanks a lot for the replies!

I want traefik1 to passthrough to traefik2, which terminates TLS and presents letsencrypt certs AND I want QUIC to work.

yes, HTTP/3 was enabled (see docker-compose.yml)

hostSNI is correctly and specifically set using environment variables (see dynamic.yml)

In summary, I still believe the fallback from QUIC to TCP is triggering a routing bug, but I had a fundamental misunderstanding of the traefik capabilities when it comes to QUIC. And it seems this is not specific, it's a general limitation on proxies and reverse proxies, that may only be solved using things like MASQUE, which requires implementation on the client side.

I changed my config this way and now QUIC works:

  • disable HTTP/3 on the TCP entrypoints (making them TCP-only entrypoints again)
  • create UDP entrypoints on on 443
  • route the entire UDP traffic to port 443 without Traefik filtering anything to the internal traefik (I don't like this, but it's the only way to have QUIC working as far as I can tell)

The internal traefik2 has TCP entrypoints only but HTTP/3 enabled.

I have crowdsec setup on the host running on external traefik1, hence the security posture is not zero, but still, I wish the QUIC proxying was a solved issue.

OpenSSL 4.0 lib now encrypts SNI (post):

The OpenSSL Library now supports Encrypted Client Hello (ECH) specified in RFC 9849, which was published this month. Applications that implement this standard will be able to encrypt sensitive information that is currently transmitted in plaintext in the TLS 1.3 handshake. In particular, ECH can protect the client’s target server name from being revealed to third parties.