How to Properly Forward HTTP_X_FORWARDED_PROTO Header in Nginx with HAProxy SSL Termination


1 views

When migrating from a simple nginx-to-Apache setup to a more complex HAProxy-nginx-Apache chain with SSL termination at HAProxy, the HTTP_X_FORWARDED_PROTO header behavior becomes crucial. The core issue emerges when nginx receives SSL-terminated traffic from HAProxy but interprets it as plain HTTP.

The traffic flow:
client → HAProxy (SSL termination) → nginx (reverse proxy) → Apache/PHP

Key observations:

  • HAProxy 1.5-dev18 with SSL support correctly sets X-Forwarded-Proto
  • nginx sees all traffic from HAProxy as HTTP (port 80)
  • The protocol header gets overwritten in transit

Here's the proper way to handle this in nginx:

server {
    listen 80;
    server_name example.com;

    # Preserve existing X-Forwarded-Proto if present
    set $original_proto $http_x_forwarded_proto;
    
    # Fallback to $scheme if header not present
    if ($original_proto = "") {
        set $original_proto $scheme;
    }

    # Forward to backend with correct protocol
    location / {
        proxy_set_header X-Forwarded-Proto $original_proto;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://apache_backend;
    }
}

Ensure your HAProxy frontend contains:

frontend https-in
    bind *:443 ssl crt /path/to/cert.pem
    http-request set-header X-Forwarded-Proto https
    default_backend nginx_servers

Verify the headers are propagating correctly:

curl -k -I https://yourdomain.com
# Check for X-Forwarded-Proto: https in Apache logs

# Alternative test for PHP applications:
<?php
var_dump($_SERVER['HTTP_X_FORWARDED_PROTO']);
?>
  • Nginx if-is-evil considerations - use map directive for complex logic
  • Header name case sensitivity (HTTP_X_FORWARDED_PROTO vs X-Forwarded-Proto)
  • Multiple proxy hops overwriting headers

For more complex scenarios, consider this pattern:

map $http_x_forwarded_proto $proxy_proto {
    default $http_x_forwarded_proto;
    ""      $scheme;
}

server {
    # ...
    proxy_set_header X-Forwarded-Proto $proxy_proto;
    # ...
}

When transitioning from a simple nginx > apache/php stack to a more complex haproxy > nginx > apache/php infrastructure with SSL termination at HAProxy, header forwarding becomes crucial. The core issue arises when Nginx receives SSL-terminated traffic but interprets it as HTTP, potentially breaking protocol-sensitive applications.

In this setup:

Client → (HTTPS) → HAProxy → (HTTP) → Nginx → Apache/PHP

The X-Forwarded-Proto header (represented as HTTP_X_FORWARDED_PROTO in server variables) needs proper propagation through each layer.

First, ensure HAProxy is properly configured:

frontend https-in
    bind *:443 ssl crt /path/to/cert.pem
    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    default_backend nginx_servers

For Nginx, we have three approaches to handle the header:

Option 1: Conditional Header Forwarding

location / {
    proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    proxy_set_header X-Forwarded-Proto $scheme if_not($http_x_forwarded_proto);
    proxy_pass http://apache_backend;
}

Option 2: Using map directive (more elegant)

map $http_x_forwarded_proto $forwarded_proto {
    default $http_x_forwarded_proto;
    ""      $scheme;
}

server {
    location / {
        proxy_set_header X-Forwarded-Proto $forwarded_proto;
        proxy_pass http://apache_backend;
    }
}

Option 3: Lua script for advanced logic (requires ngx_http_lua_module)

location / {
    access_by_lua_block {
        if ngx.var.http_x_forwarded_proto then
            ngx.req.set_header("X-Forwarded-Proto", ngx.var.http_x_forwarded_proto)
        else
            ngx.req.set_header("X-Forwarded-Proto", ngx.var.scheme)
        end
    }
    proxy_pass http://apache_backend;
}

Verify the header flow using curl:

curl -vk https://yourdomain.com -H "Host: test.example.com"

Check your Apache access logs or PHP's $_SERVER superglobal to confirm the HTTP_X_FORWARDED_PROTO value is correctly set to "https".

When dealing with WebSocket connections in this setup:

location /ws/ {
    proxy_set_header X-Forwarded-Proto $forwarded_proto;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_pass http://ws_backend;
}

The map directive solution (Option 2) has minimal performance overhead as the mapping is evaluated once during configuration load. The Lua solution offers maximum flexibility but requires additional modules.