Skip to content

Caddy v2.11 – Warning when reverting to header_up Host {hostport} in reverse_proxy block #7584

@Deltik

Description

@Deltik

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"
fi

Output:

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions