Caddy reverse_proxy + x-forwarded-for Headers

1. The problem I’m having:

We are running a caddy with frankenphp in a Digital Ocean kubernetes cluster.
Entrypoint is a load balancer service which distributes all request to two concurrent running instance of our application. Load Balancer is configured with SSL passthrough so SSL Termination is Happening in Caddy. Both Deployment share a SSL certificate.

If we log in our symfony hosts etc, it is sometimes correct (www.bibleserver.com), but sometimes incorrect (http-service).
When we activate our varnish and using ESI, symfony errors if host is not www.bibleserver.com.

Logs are with disabled varnish-service.
Http-service targets :80 section of the caddyfile.

How can we prevent this “random” change of host header ?
How can we fix http2 errors?

2. Error messages and/or full log output:

Symfony logs:

{"scheme":"https","baseurl":"","port":443,"host":"www.bibleserver.com","path":"/_fragment","query":"_path=XXX"}
{"scheme":"http","baseurl":"","port":80,"host":"http-service","path":"/_fragment","query":"_path=XXXX"}

Caddy logs:

{"duration":4.876707258,"error":"writing: http2: stream closed","level":"error","logger":"http.handlers.reverse_proxy","msg":"aborting with incomplete response","request":{"client_ip":"2001:4860:7:603::ff","headers":{"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"],"Accept-Encoding":["gzip, br"],"Accept-Language":["de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"],"Cdn-Loop":["cloudflare; loops=1"],"Cf-Connecting-Ip":["2001:4860:7:603::ff"],"Cf-Ipcountry":["AT"],"Cf-Ray":["8ec3ed6d2dad7900-CDG"],"Cf-Visitor":["{\"scheme\":\"https\"}"],"Priority":["u=4, i"],"Purpose":["prefetch"],"Referer":["https://www.google.com/"],"Sec-Ch-Ua":["\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["no-cors"],"Sec-Fetch-Site":["none"],"Sec-Purpose":["prefetch;anonymous-client-ip"],"Upgrade-Insecure-Requests":["1"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"],"X-Caddy-Forwarded":["1"],"X-Forwarded-For":["2001:4860:7:603::ff"],"X-Forwarded-Host":["www.bibleserver.com"],"X-Forwarded-Port":["443"],"X-Forwarded-Proto":["https"]},"host":"http-service","method":"GET","proto":"HTTP/2.0","remote_ip":"10.114.0.22","remote_port":"40808","tls":{"cipher_suite":4865,"proto":"h2","resumed":false,"server_name":"www.bibleserver.com","version":772},"uri":"/de/verse/Matth%C3%A4us6,13"},"ts":1733232800.649357,"upstream":"http-service:80"}

{"level":"error","ts":1733230290.3546765,"logger":"http.log.error","msg":"dial tcp 172.16.18.150:8080: connect: connection refused","request":{"remote_ip":"10.114.0.20","remote_port":"60094","client_ip":"10.114.0.20","proto":"HTTP/2.0","method":"GET","host":"www.bibleserver.com","uri":"/KJV/Psalm23%3A4","headers":{"Cdn-Loop":["cloudflare; loops=1"],"Cf-Ipcountry":["US"],"Cf-Visitor":["{\"scheme\":\"https\"}"],"User-Agent":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0 Safari/537.36"],"Cf-Connecting-Ip":["134.199.76.78"],"Accept-Encoding":["gzip, br"],"X-Forwarded-For":["134.199.76.78"],"Cf-Ray":["8ec3b0412ff00f63-EWR"],"X-Forwarded-Proto":["https"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"www.bibleserver.com"}},"duration":0.002403112,"status":502,"err_id":"mkv0q1q26","err_trace":"reverseproxy.statusError (reverseproxy.go:1269)"}

{"error":"http2: stream closed","level":"error","msg":"write error","ts":1733232796.9338708}

{"duration":5.785179248,"error":"writing: http2: stream closed","level":"error","logger":"http.handlers.reverse_proxy","msg":"aborting with incomplete response","request":{"client_ip":"134.147.21.216","headers":{"Accept":["*/*"],"Accept-Encoding":["gzip, br"],"Accept-Language":["de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"],"Cdn-Loop":["cloudflare; loops=1"],"Cf-Connecting-Ip":["134.147.21.216"],"Cf-Ipcountry":["DE"],"Cf-Ray":["8ec3f2213a41f964-DUS"],"Cf-Visitor":["{\"scheme\":\"https\"}"],"Cookie":["REDACTED"],"Mode":["same-origin"],"Priority":["u=1, i"],"Referer":["https://www.bibleserver.com/de/verse/1.Korinther1,30"],"Sec-Ch-Ua":["\"Microsoft Edge\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""],"Sec-Ch-Ua-Mobile":["?0"],"Sec-Ch-Ua-Platform":["\"Windows\""],"Sec-Fetch-Dest":["empty"],"Sec-Fetch-Mode":["cors"],"Sec-Fetch-Site":["same-origin"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"],"X-Caddy-Forwarded":["1"],"X-Forwarded-For":["134.147.21.216"],"X-Forwarded-Host":["www.bibleserver.com"],"X-Forwarded-Port":["443"],"X-Forwarded-Proto":["https"],"X-Locale":["de"],"X-Requested-With":["XMLHttpRequest"]},"host":"http-service","method":"GET","proto":"HTTP/2.0","remote_ip":"10.114.0.22","remote_port":"39226","tls":{"cipher_suite":4865,"proto":"h2","resumed":false,"server_name":"www.bibleserver.com","version":772},"uri":"/api/w53CmsKVwpfClcKYwpfClsOZw4zDmsOWw5zDmcOKw4zClsKowrXCusK7wrbCusK6wpbCnMKfwpvCnw=="},"ts":1733232994.1759555,"upstream":"http-service:80"}

3. Caddy version:

v2.8.4 / frankenphp

4. How I installed and ran Caddy:

We are using an offical frankenphp docker image.

a. System environment

Kubernetes Cluster
LoadBalancer(SSL Passthrough) → Deployment(Frankenphp, 2 Replicas)

Reverse Proxy ist actually configured to use a Varnish Cache. That is Not working at the Moment because of the changes in x-forwarded-host headers.

b. Command:

exec frankenphp run --config /etc/caddy/Caddyfile

d. My complete Caddy config:

{
            log {
                   level warn
                   output file /var/log/caddy.log
            }

            # Enable FrankenPHP
            frankenphp {
                   worker /var/www/public/index.php 12
            }

            # Configure when the directive must be executed
            order mercure after encode
            order vulcain after reverse_proxy
            order php_server before file_server
            order php before file_server

            # cloudflare proxy
            servers {
                   trusted_proxies static private_ranges 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
            }
            
            servers :80 {
                protocols h1 h2c
            }
    }

    :80 {
           root * /var/www/public

           handle /ping {
                  respond "pong"
           }

           php_server
    }

    *.bibleserver.com {
           root * /var/www/public

           @notWww not host www.bibleserver.com
           redir @notWww https://www.bibleserver.com{uri}
  
           tls produktion.online@erf.de {
                   dns cloudflare {$CLOUDFLARE_API_TOKEN}
           }

           @cloudflare header CF-Connecting-IP *

           request_header @cloudflare X-Forwarded-For {header.CF-Connecting-IP}
           request_header !@cloudflare X-Forwarded-For {header.Remote-Address}

           @buildFile path /build/*
           @noBuildFile not path /build/*

           handle @buildFile {
                   header {
                           Cache-Control "public, max-age=31536000, immutable"
                           -Via
                           -Server
                           -Vary
                           -X-Debug-Token
                           -X-Locale
                           -X-Varnish
                           -X-Powered-By
                   }
                   file_server
           }

           @maintenance {
                   file "maintenance/active.txt"
                   not path /favicon.ico
                   not path /build/roboto*
           }

           handle @maintenance {
                   try_files maintenance/maintenance.html

                   file_server {
                           status 503
                   }
           }

           header @noBuildFile {
                   Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
                   X-Frame-Options "DENY"
                   X-Content-Type-Options "nosniff"
                   X-XSS-Protection "1; mode=block"
                   -Via
                   -Server
                   -Vary
                   -X-Debug-Token
                   -X-Locale
                   -X-Varnish
                   -X-Powered-By
           }

           @useVarnish {
                   header !X-Caddy-Forwarded
                   not file
                   not path /api/chat/subscribe/*
                   not path /ping
           }

           reverse_proxy @useVarnish {
                   to h2c://varnish-service:8080 h2c://http-service 

                   # use first upstream, if healthy
                   lb_policy first

                   # configure health check
                   health_uri /ping
                   health_fails 2
                   health_timeout 11s
                   health_interval 5s
                   health_status 200

                   # remember failed upstreams for 30s
                   fail_duration 30s

                   header_up Host http-service 
                   header_up X-Forwarded-For {header.X-Forwarded-For}
                   header_up X-Forwarded-Host www.bibleserver.com
                   header_up X-Forwarded-Proto https
                   header_up X-Forwarded-Port 443
                   header_up X-Caddy-Forwarded 1
    
                   transport http {
                           versions 1.1 h2c
                   }
           }

           # enable http3 push
           push
           php_server
           encode gzip
           file_server
    }

You don’t need any of this stuff. Caddy handles the XFF header automatically for you when trusted_proxies is properly configured. Since you’re fronting with Cloudflare, you should use the client_ip_headers option to tell Caddy to read from Cf-Connecting-Ip when trusted. See Global options (Caddyfile) — Caddy Documentation. Also, you can simplify your IP list by using the GitHub - WeidiDeng/caddy-cloudflare-ip plugin instead of listing them all out.

Remove all this stuff. Caddy already handles those headers for you. See reverse_proxy (Caddyfile directive) — Caddy Documentation

Thx for your reply.

I didn’t know about the plugin and the client_ip_headers option.
I added both.

If I remove header_up Host directive in reverse_proxy I get:
“To Many Redirects”

As I understand it should work without, so where is my config wrong?

It depends what your upstream app is doing. Why is your upstream app doing a redirect?

Upstream app is a varnish.
If I see it correctly varnish is requesting our backend service http-service which points to the port :80 section of our caddyfile with all x-forwareded-* headers.

I tried to disable automatic redirects to https, but I got the same error.


    { 
            debug
            log {
                   level debug
            }

            # Enable FrankenPHP
            frankenphp {
                   worker /var/www/public/index.php 2
            }
    
            # Configure when the directive must be executed
            order mercure after encode
            order vulcain after reverse_proxy
            order php_server before file_server
            order php before file_server

            # cloudflare proxy
            servers {
                   trusted_proxies static private_ranges 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
                   client_ip_headers CF-Connecting-IP X-Forwarded-For X-Real-IP Remote-Address
            }
    
            servers :80 {
                auto_https disable_redirects
                protocols h1 h2c
            }
    }

    :80 {
           root * /var/www/public
    
           handle /ping {
                  respond "pong"
           }
    
           php_server  
    }

I don’t understand what you’re logging in to. I don’t know what you mean exactly by “it” when you say “it” is sometimes correct but sometimes incorrect.

You refer to Host header but the title of the post refers to X-Forwarded-For. There is ambiguity there.

There is no evidence (curl output, debug logs, upstream server logs) demonstrating the issue. I see only http2 write errors and a refused connection to upstream.

As Francis outlines, X-Forwarded-For should always automatically reflect the hostname you requested. Host should always be the same as what the client requested. You should only need to override these if your upstream app requires something different. Whether you set them or not, these headers should be consistent. There is nothing Caddy does to randomise these.

Caddy knows port :80 is HTTP by default and won’t try to redirect explicit configuration for this site to HTTPS.

Caddy shouldn’t be issuing a redirect here. It must be coming from somewhere else. There will be evidence of this in your debug logs, or in your upstream logs.

Here is our varnish request log:

*   << Request  >> 32944     
-   Begin          req 32769 rxreq
-   Timestamp      Start: 1733300042.312482 0.000000 0.000000
-   ReqProtocol    HTTP/2.0
-   Timestamp      Req: 1733300042.312482 0.000000 0.000000
-   VCL_use        boot
-   ReqStart       192.168.2.77 36320 http
-   ReqMethod      GET
-   ReqURL         /
-   ReqProtocol    HTTP/2.0
-   ReqHeader      host: www.bibleserver.com
-   ReqHeader      scheme: http
-   ReqHeader      sec-fetch-site: none
-   ReqHeader      sec-fetch-mode: navigate
-   ReqHeader      accept-language: de
-   ReqHeader      x-forwarded-for: 10.114.0.23
-   ReqHeader      sec-ch-ua-mobile: ?0
-   ReqHeader      upgrade-insecure-requests: 1
-   ReqHeader      cache-control: max-age=0
-   ReqHeader      priority: u=0, i
-   ReqHeader      sec-ch-ua-platform: "macOS"
-   ReqHeader      sec-fetch-user: ?1
-   ReqHeader      accept-encoding: gzip, deflate, br, zstd
-   ReqHeader      user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
-   ReqHeader      accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
-   ReqHeader      sec-fetch-dest: document
-   ReqHeader      sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
-   ReqHeader      x-forwarded-proto: https
-   ReqHeader      x-forwarded-host: dev.bibleserver.com
-   ReqUnset       x-forwarded-for: 10.114.0.23
-   ReqHeader      X-Forwarded-For: 10.114.0.23, 192.168.2.77
-   ReqHeader      Via: 1.1 varnish-deployment-656cf474d4-bzlrg (Varnish/7.4)
-   VCL_call       RECV
-   ReqHeader      Surrogate-Capability: bse=ESI/1.0
-   ReqUnset       X-Forwarded-For: 10.114.0.23, 192.168.2.77
-   ReqHeader      X-Forwarded-For: 10.114.0.23, 192.168.2.77
-   ReqUnset       x-forwarded-host: www.bibleserver.com
-   ReqHeader      X-Forwarded-Host: www.bibleserver.com
-   ReqUnset       X-Forwarded-Host: www.bibleserver.com
-   ReqHeader      X-Forwarded-Host: www.bibleserver.com
-   ReqHeader      X-Forwarded-Port: 443
-   ReqUnset       x-forwarded-proto: https
-   ReqHeader      X-Forwarded-Proto: https
-   ReqHeader      X-Locale: 
-   ReqUnset       X-Locale: 
-   ReqHeader      X-Locale: de
-   VCL_return     hash
-   ReqUnset       accept-encoding: gzip, deflate, br, zstd
-   ReqHeader      Accept-Encoding: gzip
-   VCL_call       HASH
-   VCL_return     lookup
-   HitMiss        98312 119.959274
-   VCL_call       MISS
-   VCL_return     fetch
-   Link           bereq 98313 fetch
-   Timestamp      Fetch: 1733300042.324274 0.011792 0.011792
-   RespProtocol   HTTP/1.1
-   RespStatus     308
-   RespReason     Permanent Redirect
-   RespHeader     Location: https://www.bibleserver.com/
-   RespHeader     Server: Caddy
-   RespHeader     Date: Wed, 04 Dec 2024 08:14:02 GMT
-   RespHeader     Content-Length: 0
-   RespHeader     x-url: /
-   RespHeader     x-host: 
-   RespHeader     x-tags: 
-   RespHeader     X-Varnish: 32944
-   RespHeader     Age: 0
-   RespHeader     Via: 1.1 varnish-deployment-656cf474d4-bzlrg (Varnish/7.4)
-   VCL_call       DELIVER
-   RespHeader     X-Cache: MISS
-   RespUnset      x-url: /
-   RespUnset      x-host: 
-   RespUnset      x-tags: 
-   VCL_return     deliver
-   Timestamp      Process: 1733300042.324330 0.011848 0.000056
-   Filters        
-   RespProtocol   HTTP/2.0
-   Timestamp      Resp: 1733300042.324447 0.011965 0.000117
-   ReqAcct        21 0 21 187 0 187
-   End            

Sry for ambiguity, first problem was that in the logs of our symfony app host changes randomly between host header, I manipulated, and the x-forwarded-Host. (with or without varnish)

Without varnish requests go directly to http-service → :80
With varnish requests go varnish → http-service :80

I understand that I should not change the host header in reverse_proxy. After I removed it from reverse_proxy I got “To Many redirects” and our backend application behind caddy :80 is not reached anymore.

So, you’ve got requests coming in directly to Caddy, which load balances between 1) Varnish and 2) itself-on-port-80? And Varnish proxies to Caddy-on-port-80 with caching?

Is that correct?

If so, I think I see a bit of a logical problem here:

  • Request for www.bibleserver.com comes in to Caddy over HTTPS :white_check_mark:
  • Caddy requests www.bibleserver.com from Varnish :white_check_mark:
  • Varnish requests www.bibleserver.com over HTTP from Caddy :grimacing:
  • Caddy issues a HTTP->S redirect for the *.bibleserver.com site block :x:

header_up Host http-service actually precluded this issue by making Varnish request http-service from Caddy over port 80, which was handled by the :80 site block, not by the *.bibleserver.com site block. But I’m guessing that’s not an option because your symfony app needs Host to be www.bibleserver.com?

Maybe try moving your :80 site block up one port. If you use :81 and Varnish requests www.bibleserver.com over HTTP on port 81, it shouldn’t get picked up by the *.bibleserver.com site block (which implicitly listens on the default HTTP and HTTPS ports).

Ah, I got it.
I was not expecting this implicit redirect.

Now it is working as expected. Thank you very much!

Could this resolve our http2 streaming errors too?

I’m not sure about the http2 streaming errors.

Those “aborting with incomplete response” errors usually happen when the client disconnects before receiving a full response, so usually I don’t expect that means you need to fix anything server-side.

Are you seeing any specific problematic behaviour/issues with your app when you use it that correlate to these streaming errors?

Ok, thx. No we are not seeing any issues, only these errors.

Now we are having:


{
            log {
                   level warn
                   output file /var/log/caddy.log
            }

            # Enable FrankenPHP
            frankenphp {
                   worker /var/www/public/index.php 12
            }

            # Configure when the directive must be executed
            order mercure after encode
            order vulcain after reverse_proxy
            order php_server before file_server
            order php before file_server

            # cloudflare proxy
            servers {
                   trusted_proxies combine {
                          static private_ranges
                          cloudflare {
                                 interval 12h
                                 timeout 15s
                          }
                   }
                   client_ip_headers CF-Connecting-IP X-Forwarded-For X-Real-IP
            }
    }

    :8081 {
           root * /var/www/public

           log {
                  output file /var/log/access_intern.log
           }
    
           handle /ping {
                  respond "pong"
           }

           php_server
    }

    *.bibleserver.com {
           root * /var/www/public
           
           log {
                  output file /var/log/access.log
           }
    
           @notWww not host www.bibleserver.com
           redir @notWww https://www.bibleserver.com{uri}
  
           tls produktion.online@erf.de {
                   dns cloudflare {$CLOUDFLARE_API_TOKEN}
           }

           @buildFile path /build/*
           @noBuildFile not path /build/*

           handle @buildFile {
                   header {
                           Cache-Control "public, max-age=31536000, immutable"
                           -Via
                           -Server
                           -Vary
                           -X-Debug-Token
                           -X-Locale
                           -X-Varnish
                           -X-Powered-By
                   }
                   file_server
           }

           @maintenance {
                   file "maintenance/active.txt"
                   not path /favicon.ico
                   not path /build/roboto*
           }

           handle @maintenance {
                   try_files maintenance/maintenance.html

                   file_server {
                           status 503
                   }
           }

           header @noBuildFile {
                   Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
                   X-Frame-Options "DENY"
                   X-Content-Type-Options "nosniff"
                   X-XSS-Protection "1; mode=block"
                   -Via
                   -Server
                   -Vary
                   -X-Debug-Token
                   -X-Locale
                   -X-Varnish
                   -X-Powered-By
           }

           @useVarnish {
                   header !X-Caddy-Forwarded
                   not file
                   not path /api/chat/subscribe/*
                   not path /ping
           }

           reverse_proxy @useVarnish {
                   to http://erf-bibleserver-varnish-service:8080 http://erf-bibleserver-internal-load-balancer:8081

                   # use first upstream, if healthy
                   lb_policy first

                   # configure health check
                   health_uri /ping
                   health_fails 2
                   health_timeout 11s
                   health_interval 5s
                   health_status 200
           }

           # enable http3 push
           push
           php_server
           encode gzip
           file_server
    }

Sometimes we get now a 525 handshake failed. Is there anything in wrong order?

Additionally it seems that caddy is not always using the reverse proxy but condition @useVarnish is always false.

How could that be?

That’s a Cloudflare error indicating they couldn’t establish a secure connection. I can’t say I’ve ever seen this as an intermittent issue because if Caddy can provide a secure connection it generally always will succeed at doing that. So, I normally expect to see 100% failures or 100% successes there. Is there a load balancer in front of Caddy maybe directing intermittent requests to a nonfunctional server?

Do you get anything in the Caddy logs? Try turning on debug, look for log output at the same timestamp as the 525.

The simplest possibility is that Caddy isn’t actually receiving any requests that match.

Try turning on debug and make a curl request specifically crafted to match, and post the output if it fails.

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.