-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Open
Description
Issue Details
When following the official guidance to revert to the pre-Caddy v2.11 behavior of
reverse_proxy https://example.com {
header_up Host {hostport}
}
… Caddy logs the following warning upon loading the config:
{"level":"warn","ts":1774098226.3280172,"msg":"Unnecessary header_up Host: the reverse proxy's default behavior is to pass headers to the upstream"}Although the default behavior changed in #7454, the warning mechanism appears to have been left behind with the old default, so when the formerly redundant directive is added, the warning erroneously shows.
Reproducer Script
This AI-generated single file script below demonstrates the bug. The dependencies are docker compose, openssl, and curl.
#!/bin/bash
# Reproducer for Caddy bug: "Unnecessary header_up Host" warning is incorrect
# when reverse_proxy upstream is an IP address.
#
# In Caddy 2.11+, reverse_proxy to an HTTPS IP upstream sends
# Host: <upstream_ip> instead of the original Host header.
# Adding `header_up Host {hostport}` fixes this, but Caddy warns:
# "Unnecessary header_up Host: the reverse proxy's default behavior
# is to pass headers to the upstream"
#
# This warning is wrong — without `header_up Host {hostport}`, the
# upstream receives the IP as the Host header, not the original host.
#
# Requires: docker compose, openssl, curl
# Usage: ./reproduce.sh
set -euo pipefail
WORKDIR="$(mktemp -d)"
cleanup() {
echo ""
echo "Cleaning up..."
docker compose -f "$WORKDIR/compose.yaml" down --remove-orphans 2>/dev/null || true
rm -rf "$WORKDIR"
}
trap cleanup EXIT
echo "=== Generating backend self-signed cert ==="
openssl req -x509 -nodes -days 1 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout "$WORKDIR/backend-key.pem" -out "$WORKDIR/backend-cert.pem" \
-subj "/CN=backend" 2>/dev/null
echo "Done."
echo ""
echo "=== Writing NGINX config (echoes Host header back to client) ==="
cat > "$WORKDIR/nginx.conf" <<'NGINX'
events {}
http {
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
location / {
add_header Content-Type text/plain;
return 200 "Host header received: $host\n";
}
}
}
NGINX
echo ""
echo "=== Writing compose.yaml ==="
cat > "$WORKDIR/compose.yaml" <<'COMPOSE'
services:
backend:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./backend-cert.pem:/etc/nginx/ssl/cert.pem:ro
- ./backend-key.pem:/etc/nginx/ssl/key.pem:ro
caddy-without-fix:
image: caddy:2.11.2
depends_on: [backend]
ports: ["8081:8080"]
volumes:
- ./Caddyfile.without-fix:/etc/caddy/Caddyfile:ro
caddy-with-fix:
image: caddy:2.11.2
depends_on: [backend]
ports: ["8082:8080"]
volumes:
- ./Caddyfile.with-fix:/etc/caddy/Caddyfile:ro
COMPOSE
echo ""
echo "=== Starting backend to resolve its IP ==="
docker compose -f "$WORKDIR/compose.yaml" up -d backend
sleep 1
BACKEND_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$(docker compose -f "$WORKDIR/compose.yaml" ps -q backend)")
echo "Backend IP: $BACKEND_IP"
echo ""
echo "=== Writing Caddyfiles ==="
cat > "$WORKDIR/Caddyfile.without-fix" <<EOF
{
auto_https off
admin off
}
:8080 {
reverse_proxy https://$BACKEND_IP {
transport http {
tls_insecure_skip_verify
}
}
}
EOF
cat > "$WORKDIR/Caddyfile.with-fix" <<EOF
{
auto_https off
admin off
}
:8080 {
reverse_proxy https://$BACKEND_IP {
header_up Host {hostport}
transport http {
tls_insecure_skip_verify
}
}
}
EOF
echo ""
echo "=== Starting all services ==="
docker compose -f "$WORKDIR/compose.yaml" up -d --no-recreate
sleep 3
echo ""
echo "=== Test 1: WITHOUT header_up Host ==="
echo "Request: curl -H 'Host: myapp.example.com' http://localhost:8081/"
RESULT_NO_FIX=$(curl -s -m 5 -H "Host: myapp.example.com" http://localhost:8081/ || true)
if [ -z "$RESULT_NO_FIX" ]; then
echo "Result: (no response — timed out)"
else
echo "Result: $RESULT_NO_FIX"
fi
echo ""
echo "=== Test 2: WITH header_up Host {hostport} ==="
echo "Request: curl -H 'Host: myapp.example.com' http://localhost:8082/"
RESULT_WITH_FIX=$(curl -s -m 5 -H "Host: myapp.example.com" http://localhost:8082/ || true)
if [ -z "$RESULT_WITH_FIX" ]; then
echo "Result: (no response — timed out)"
else
echo "Result: $RESULT_WITH_FIX"
fi
echo ""
echo "=== Caddy warning when using header_up Host {hostport} ==="
docker compose -f "$WORKDIR/compose.yaml" logs caddy-with-fix 2>&1 | grep -i "unnecessary" || echo "(no warning)"
echo ""
echo "=== Summary ==="
echo ""
echo "Without 'header_up Host {hostport}': ${RESULT_NO_FIX:-(timeout)}"
echo "With 'header_up Host {hostport}': ${RESULT_WITH_FIX:-(timeout)}"
echo ""
if echo "$RESULT_WITH_FIX" | grep -q "myapp.example.com"; then
if echo "$RESULT_NO_FIX" | grep -q "myapp.example.com"; then
echo "Bug NOT reproduced — both tests passed the original Host header."
else
echo "BUG CONFIRMED:"
echo " - 'header_up Host {hostport}' is NECESSARY to preserve the original"
echo " Host header when proxying to an HTTPS upstream by IP address."
echo " - Yet Caddy warns it is 'Unnecessary'."
fi
else
echo "Unexpected: even with the fix, the original Host was not passed through."
echo "Check container logs with: docker compose -f $WORKDIR/compose.yaml logs"
fiOutput:
deltik@deltique [~]$ /tmp/caddy-bug-repro/reproduce.sh
=== Generating backend self-signed cert ===
Done.
=== Writing NGINX config (echoes Host header back to client) ===
=== Writing compose.yaml ===
=== Starting backend to resolve its IP ===
Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
>>>> Executing external compose provider "~/.docker/cli-plugins/docker-compose". Please refer to the documentation for details. <<<<
WARN[0000] No services to build
[+] up 2/2
✔ Network tmpftnvxw7qb1_default Created 0.0s
✔ Container tmpftnvxw7qb1-backend-1 Created 1.5s
Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
>>>> Executing external compose provider "~/.docker/cli-plugins/docker-compose". Please refer to the documentation for details. <<<<
Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
Backend IP: 10.89.1.2
=== Writing Caddyfiles ===
=== Starting all services ===
Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
>>>> Executing external compose provider "~/.docker/cli-plugins/docker-compose". Please refer to the documentation for details. <<<<
WARN[0000] No services to build
[+] up 3/3
✔ Container tmpftnvxw7qb1-backend-1 Running 0.0s
✔ Container tmpftnvxw7qb1-caddy-without-fix-1 Created 1.7s
✔ Container tmpftnvxw7qb1-caddy-with-fix-1 Created 1.7s
=== Test 1: WITHOUT header_up Host ===
Request: curl -H 'Host: myapp.example.com' http://localhost:8081/
Result: Host header received: 10.89.1.2
=== Test 2: WITH header_up Host {hostport} ===
Request: curl -H 'Host: myapp.example.com' http://localhost:8082/
Result: Host header received: myapp.example.com
=== Caddy warning when using header_up Host {hostport} ===
caddy-with-fix-1 | {"level":"warn","ts":1774098226.3280172,"msg":"Unnecessary header_up Host: the reverse proxy's default behavior is to pass headers to the upstream"}
=== Summary ===
Without 'header_up Host {hostport}': Host header received: 10.89.1.2
With 'header_up Host {hostport}': Host header received: myapp.example.com
BUG CONFIRMED:
- 'header_up Host {hostport}' is NECESSARY to preserve the original
Host header when proxying to an HTTPS upstream by IP address.
- Yet Caddy warns it is 'Unnecessary'.
Cleaning up...
Assistance Disclosure
AI used
If AI was used, describe the extent to which it was used.
Reproducer script written by Anthropic Claude Opus 4.6; all other content written by me (a human)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels