Can't get Caddy --HTTPS--> Caddy --HTTP--> upstream proxy to work

1. The problem I’m having:

I would like to have the following setup with two servers running Caddy:

client --HTTP--> Caddy1 --HTTPS--> Caddy2 --HTTP--> upstream

The Caddy1 and Caddy2 instances runs on two different servers, but Caddy2 and upstream runs on the same server.
I would like the connection from Caddy1 to Caddy2 to be encrypted.

However, when testing this with both Caddy1, Caddy2 and upstream running on the same computer, I can’t get my request to Caddy1 to reach upstream.

Here is the config for Caddy1 and Caddy2:

# Caddyfile for Caddy1
{
	debug
}

http://s1.localhost:8002 {
	reverse_proxy :8001 {
		# Adding this doesn't solve the problem either:
		# header_up Host {upstream_hostport} 
		transport http {
			tls_server_name s2.localhost
		}
	}
}
# Caddyfile for Caddy2
{
	debug
	ocsp_stapling off
	http_port 32132 # Prevent binding to port 80
}

https://s2.localhost:8001 {
	reverse_proxy :8000
}

The upstream is just running python -mhttp.server in this test.

Sending requests directly to the upstream works:

$ curl -vI http://localhost:8000/
* Host localhost:8000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000
* using HTTP/1.x
> HEAD / HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.11.1
> Accept: */*
> 
* Request completely sent off
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
HTTP/1.0 200 OK
< Server: SimpleHTTP/0.6 Python/3.13.1
Server: SimpleHTTP/0.6 Python/3.13.1
< Date: Thu, 16 Jan 2025 15:32:38 GMT
Date: Thu, 16 Jan 2025 15:32:38 GMT
< Content-type: text/html; charset=utf-8
Content-type: text/html; charset=utf-8
< Content-Length: 10733
Content-Length: 10733
< 

* shutting down connection #0

Sending request to Caddy2 also works:

$ curl -vI https://s2.localhost:8001/
* Host s2.localhost:8001 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:8001...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: none
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: 
*  start date: Jan 16 15:20:16 2025 GMT
*  expire date: Jan 17 03:20:16 2025 GMT
*  subjectAltName: host "s2.localhost" matched cert's "s2.localhost"
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify ok.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 2: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* Connected to s2.localhost (127.0.0.1) port 8001
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://s2.localhost:8001/
* [HTTP/2] [1] [:method: HEAD]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: s2.localhost:8001]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.11.1]
* [HTTP/2] [1] [accept: */*]
> HEAD / HTTP/2
> Host: s2.localhost:8001
> User-Agent: curl/8.11.1
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Request completely sent off
< HTTP/2 200 
HTTP/2 200 
< alt-svc: h3=":8001"; ma=2592000
alt-svc: h3=":8001"; ma=2592000
< content-type: text/html; charset=utf-8
content-type: text/html; charset=utf-8
< date: Thu, 16 Jan 2025 15:33:37 GMT
date: Thu, 16 Jan 2025 15:33:37 GMT
< server: Caddy
server: Caddy
< server: SimpleHTTP/0.6 Python/3.13.1
server: SimpleHTTP/0.6 Python/3.13.1
< content-length: 10733
content-length: 10733
< 

* Connection #0 to host s2.localhost left intact

However sending a request to Caddy2 just produces a 200 OK response from Caddy1 without proxying it to the upstream:

$ curl -vI http://s1.localhost:8002/
* Host s1.localhost:8002 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:8002...
* Connected to s1.localhost (127.0.0.1) port 8002
* using HTTP/1.x
> HEAD / HTTP/1.1
> Host: s1.localhost:8002
> User-Agent: curl/8.11.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Date: Thu, 16 Jan 2025 15:34:53 GMT
Date: Thu, 16 Jan 2025 15:34:53 GMT
< Server: Caddy
Server: Caddy
< Server: Caddy
Server: Caddy
< 

* Connection #0 to host s1.localhost left intact

2. Error messages and/or full log output:

Logs when sending one request to Caddy1 with curl -vI http://s1.localhost:8002/:

2025/01/16 15:36:27.992	INFO	using adjacent Caddyfile
2025/01/16 15:36:27.994	INFO	adapted config to JSON	{"adapter": "caddyfile"}
2025/01/16 15:36:27.996	INFO	admin	admin endpoint started	{"address": "localhost:2019", "enforce_origin": false, "origins": ["//[::1]:2019", "//127.0.0.1:2019", "//localhost:2019"]}
2025/01/16 15:36:27.996	DEBUG	http.auto_https	adjusted config	{"tls": {"automation":{"policies":[{"disable_ocsp_stapling":true}]},"disable_ocsp_stapling":true}, "http": {"http_port":32133,"servers":{"srv0":{"listen":[":8002"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","transport":{"protocol":"http","tls":{"server_name":"s2.localhost"}},"upstreams":[{"dial":":8001"}]}]}]}],"terminal":true}],"automatic_https":{"skip":["s1.localhost"]}}}}}
2025/01/16 15:36:27.997	INFO	tls.cache.maintenance	started background certificate maintenance	{"cache": "0xc000646700"}
2025/01/16 15:36:27.998	INFO	tls	storage cleaning happened too recently; skipping for now	{"storage": "FileStorage:/home/tyilo/.local/share/caddy", "instance": "12e300f0-f82d-4a93-9f5d-fcc4bbec0be1", "try_again": "2025/01/17 15:36:27.998", "try_again_in": 86399.999999774}
2025/01/16 15:36:27.998	INFO	tls	finished cleaning storage units
2025/01/16 15:36:28.001	DEBUG	http	starting server loop	{"address": "0.0.0.0:8002", "tls": false, "http3": false}
2025/01/16 15:36:28.001	INFO	http.log	server running	{"name": "srv0", "protocols": ["h1", "h2", "h3"]}
2025/01/16 15:36:28.001	INFO	autosaved config (load with --resume flag)	{"file": "/home/tyilo/.local/share/caddy/autosave.json"}
2025/01/16 15:36:28.001	INFO	serving initial configuration
2025/01/16 15:36:33.902	DEBUG	http.handlers.reverse_proxy	selected upstream	{"dial": ":8001", "total_upstreams": 1}
2025/01/16 15:36:33.907	DEBUG	http.handlers.reverse_proxy	upstream roundtrip	{"upstream": ":8001", "duration": 0.004832662, "request": {"remote_ip": "127.0.0.1", "remote_port": "58656", "client_ip": "127.0.0.1", "proto": "HTTP/1.1", "method": "HEAD", "host": "s1.localhost:8002", "uri": "/", "headers": {"X-Forwarded-Host": ["s1.localhost:8002"], "User-Agent": ["curl/8.11.1"], "Accept": ["*/*"], "X-Forwarded-For": ["127.0.0.1"], "X-Forwarded-Proto": ["http"]}}, "headers": {"Alt-Svc": ["h3=\":8001\"; ma=2592000"], "Server": ["Caddy"], "Date": ["Thu, 16 Jan 2025 15:36:33 GMT"]}, "status": 200}

Logs from Caddy2:

2025/01/16 15:36:26.395	INFO	using adjacent Caddyfile
2025/01/16 15:36:26.396	INFO	adapted config to JSON	{"adapter": "caddyfile"}
2025/01/16 15:36:26.399	INFO	admin	admin endpoint started	{"address": "localhost:2019", "enforce_origin": false, "origins": ["//localhost:2019", "//[::1]:2019", "//127.0.0.1:2019"]}
2025/01/16 15:36:26.399	INFO	http.auto_https	enabling automatic HTTP->HTTPS redirects	{"server_name": "srv0"}
2025/01/16 15:36:26.399	INFO	tls.cache.maintenance	started background certificate maintenance	{"cache": "0xc000119e00"}
2025/01/16 15:36:26.400	DEBUG	http.auto_https	adjusted config	{"tls": {"automation":{"policies":[{"subjects":["s2.localhost"],"disable_ocsp_stapling":true},{"disable_ocsp_stapling":true}]},"disable_ocsp_stapling":true}, "http": {"http_port":32132,"servers":{"remaining_auto_https_redirects":{"listen":[":32132"],"routes":[{},{}]},"srv0":{"listen":[":8001"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":":8000"}]}]}]}],"terminal":true}],"tls_connection_policies":[{}],"automatic_https":{}}}}}
2025/01/16 15:36:26.400	INFO	pki.ca.local	root certificate is already trusted by system	{"path": "storage:pki/authorities/local/root.crt"}
2025/01/16 15:36:26.401	INFO	tls	storage cleaning happened too recently; skipping for now	{"storage": "FileStorage:/home/tyilo/.local/share/caddy", "instance": "12e300f0-f82d-4a93-9f5d-fcc4bbec0be1", "try_again": "2025/01/17 15:36:26.401", "try_again_in": 86399.999999611}
2025/01/16 15:36:26.401	INFO	tls	finished cleaning storage units
2025/01/16 15:36:26.404	INFO	http	enabling HTTP/3 listener	{"addr": ":8001"}
2025/01/16 15:36:26.404	INFO	failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB). See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details.
2025/01/16 15:36:26.404	DEBUG	http	starting server loop	{"address": "0.0.0.0:8001", "tls": true, "http3": true}
2025/01/16 15:36:26.404	INFO	http.log	server running	{"name": "srv0", "protocols": ["h1", "h2", "h3"]}
2025/01/16 15:36:26.404	DEBUG	http	starting server loop	{"address": "0.0.0.0:32132", "tls": false, "http3": false}
2025/01/16 15:36:26.404	INFO	http.log	server running	{"name": "remaining_auto_https_redirects", "protocols": ["h1", "h2", "h3"]}
2025/01/16 15:36:26.404	INFO	http	enabling automatic TLS certificate management	{"domains": ["s2.localhost"]}
2025/01/16 15:36:26.404	DEBUG	tls.cache	added certificate to cache	{"subjects": ["s2.localhost"], "expiration": "2025/01/17 03:20:17.000", "managed": true, "issuer_key": "local", "hash": "55a6f235b9eabcc77fbca1043d3c30fe8ac54c55c4fab1374ab669e021226515", "cache_size": 1, "cache_capacity": 10000}
2025/01/16 15:36:26.404	DEBUG	events	event	{"name": "cached_managed_cert", "id": "3d5a91cb-ac0a-45d0-813b-f7340827ba36", "origin": "tls", "data": {"sans":["s2.localhost"]}}
2025/01/16 15:36:26.404	INFO	autosaved config (load with --resume flag)	{"file": "/home/tyilo/.local/share/caddy/autosave.json"}
2025/01/16 15:36:26.404	INFO	serving initial configuration
2025/01/16 15:36:33.904	DEBUG	events	event	{"name": "tls_get_certificate", "id": "1c6731e1-5686-469d-b5c4-8babb4cce7ee", "origin": "tls", "data": {"client_hello":{"CipherSuites":[49195,49199,49196,49200,52393,52392,49161,49171,49162,49172,156,157,47,53,49170,10,4865,4866,4867],"ServerName":"s2.localhost","SupportedCurves":[29,23,24,25],"SupportedPoints":"AA==","SignatureSchemes":[2052,1027,2055,2053,2054,1025,1281,1537,1283,1539,513,515],"SupportedProtos":["h2","http/1.1"],"SupportedVersions":[772,771],"RemoteAddr":{"IP":"127.0.0.1","Port":58858,"Zone":""},"LocalAddr":{"IP":"127.0.0.1","Port":8001,"Zone":""}}}}
2025/01/16 15:36:33.904	DEBUG	tls.handshake	choosing certificate	{"identifier": "s2.localhost", "num_choices": 1}
2025/01/16 15:36:33.904	DEBUG	tls.handshake	default certificate selection results	{"identifier": "s2.localhost", "subjects": ["s2.localhost"], "managed": true, "issuer_key": "local", "hash": "55a6f235b9eabcc77fbca1043d3c30fe8ac54c55c4fab1374ab669e021226515"}
2025/01/16 15:36:33.904	DEBUG	tls.handshake	matched certificate in cache	{"remote_ip": "127.0.0.1", "remote_port": "58858", "subjects": ["s2.localhost"], "managed": true, "expiration": "2025/01/17 03:20:17.000", "hash": "55a6f235b9eabcc77fbca1043d3c30fe8ac54c55c4fab1374ab669e021226515"}

3. Caddy version:

I used v2.8.4 so the logs would reflect that.

Using v2.9.1 downloaded from the GitHub releases page shows the same behavior.

4. How I installed and ran Caddy:

a. System environment:

Arch Linux, installed with pacman

b. Command:

For both Caddy1 and Caddy2 (in two different directories):

caddy run

d. My complete Caddy config:

See above

Using the following Caddyfile for Caddy1 doesn’t work either:

{
    debug
}

http://s1.localhost:8002 {
    reverse_proxy https://s2.localhost:8001
}

Sorry. I missed this thread. The Host header sent to upstream isn’t matching the expected address per upstream because Caddy defaults to sending the original Host header received in the client request. You can override it.

http://s1.localhost:8002 {
    reverse_proxy https://s2.localhost:8001 {
        header_up Host {upstream_hostport}
    }
}