Apache as a reverse proxy: ProxyPass, WebSockets and headers

Updated November 2026

In reverse proxy mode Apache receives the browser’s request and passes it on to an application server, such as Node.js, a Python app under Gunicorn or Uvicorn, or Tomcat, then sends the answer back. Apache keeps the jobs it is good at: HTTPS, HTTP/2, static files, logging and access control. Two directives do most of the work, ProxyPass and ProxyPassReverse, and since httpd 2.4.47 WebSockets need only one extra word.

Enable the modules

You need mod_proxy plus a protocol module, usually mod_proxy_http. mod_headers is used below for extra request headers.

# Debian/Ubuntu
sudo a2enmod proxy proxy_http headers
sudo systemctl restart apache2

# RHEL/Fedora: already loaded by /etc/httpd/conf.modules.d/00-proxy.conf
sudo apachectl -M | grep -E 'proxy_module|proxy_http_module'

On RHEL-family systems SELinux does not let httpd open network connections by default (the httpd_can_network_connect boolean is off), so connections to the backend fail with a permission error in the error log. Allow them once:

sudo setsebool -P httpd_can_network_connect 1

A basic reverse proxy

Example: a Node.js app listens on 127.0.0.1:3000 and should appear at https://app.example.com/.

<VirtualHost *:443>
    ServerName app.example.com
    # SSLEngine and certificate lines: see the HTTPS guide

    ProxyRequests Off
    ProxyPreserveHost On
    ProxyPass        "/" "http://127.0.0.1:3000/"
    ProxyPassReverse "/" "http://127.0.0.1:3000/"

    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>
ProxyRequests Off
Turns off forward proxying. It is already the default; write it anyway so nobody switches it on by mistake. An open forward proxy lets anyone on the internet send traffic through your server. ProxyPass works with it off.
ProxyPass
Maps a URL path to a backend URL. Requests for /orders?id=7 go to http://127.0.0.1:3000/orders?id=7.
ProxyPassReverse
Rewrites Location, Content-Location and URI headers in the backend’s redirects. Without it, a redirect from the app may send the browser to http://127.0.0.1:3000/login.
ProxyPreserveHost On
Passes the browser’s Host header (app.example.com) instead of 127.0.0.1:3000. The default is Off, and the httpd documentation suggests leaving it off unless the backend needs the original host name. Many apps do, because they build absolute links or check allowed hosts from it.

Then test and reload: sudo apachectl configtest && sudo systemctl reload apache2 (httpd on RHEL).

Paths, slashes and exceptions

Headers the backend receives

mod_proxy_http adds three headers by default (ProxyAddHeaders On):

HeaderContains
X-Forwarded-ForThe client’s IP address
X-Forwarded-HostThe Host header the client sent
X-Forwarded-ServerThe proxy’s own server name

Apache does not add X-Forwarded-Proto. Without it, an app behind an HTTPS proxy thinks requests arrive over HTTP and may build http:// links or loop on “redirect to HTTPS”. That is why the example sets it with RequestHeader set X-Forwarded-Proto "https". If a request already passed through another proxy, X-Forwarded-For holds several comma-separated addresses, and the client is the first one only if every proxy in between is trusted.

Configure the app to trust only your proxy. Most frameworks and application servers read these headers only from proxies you list in their “trusted proxy” or “forwarded allow IPs” setting; see their documentation. Allow only 127.0.0.1 or your proxy’s address; otherwise any client can fake its IP with a header.

When Apache is the backend

If Apache itself sits behind a load balancer, a CDN or nginx, its logs and Require ip rules see the proxy’s address. mod_remoteip fixes that (sudo a2enmod remoteip on Debian; loaded by default on Fedora):

RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.5
# for proxies with public addresses use RemoteIPTrustedProxy instead

Apache then uses the client’s address for Require ip, mod_status and the %a log field; %{c}a still logs the proxy’s address. If you list no trusted proxies, mod_remoteip trusts every host that sends the header, which lets anyone fake an address. Behind a TCP load balancer such as HAProxy that uses the PROXY protocol, use RemoteIPProxyProtocol On instead (httpd 2.4.31 and later).

WebSockets

Since httpd 2.4.47, mod_proxy_http handles WebSocket upgrades itself. Add upgrade=websocket to the ProxyPass line:

ProxyPass        "/socket.io/" "http://127.0.0.1:3000/socket.io/" upgrade=websocket
ProxyPass        "/" "http://127.0.0.1:3000/"
ProxyPassReverse "/" "http://127.0.0.1:3000/"

Apache forwards the client’s Upgrade request to the backend and switches to a tunnel only if the backend answers 101 Switching Protocols; otherwise the request is handled as normal HTTP. That means the WebSocket path can also serve the long-polling fallback some libraries use. mod_proxy_wstunnel is deprecated; you only need it, with ws:// URLs, on httpd older than 2.4.47. Browsers connected over HTTP/2 open a separate HTTP/1.1 connection for the WebSocket unless you set H2WebSockets on (2.4.58 and later), which carries WebSockets inside HTTP/2 (RFC 8441).

A tunnel that stays quiet longer than the proxy timeout is closed. For chat or live dashboards, either have the app send pings more often than the timeout, or raise it for that path, for example upgrade=websocket timeout=3600.

Timeouts and backend failures

ProxyPass "/" "http://127.0.0.1:3000/" connectiontimeout=5 timeout=120

Examples for common backends

Python (Gunicorn or Uvicorn)

ProxyPreserveHost On
ProxyPass        "/static/" "!"
Alias            "/static/" "/srv/myapp/static/"
ProxyPass        "/" "unix:/run/myapp/gunicorn.sock|http://localhost/"
ProxyPassReverse "/" "http://localhost/"
RequestHeader set X-Forwarded-Proto "https"
<Directory "/srv/myapp/static">
    Require all granted
</Directory>

Apache’s user must be allowed to open the socket file. A TCP port such as http://127.0.0.1:8000/ works just as well.

Java (Tomcat, Spring Boot, Jetty)

ProxyPreserveHost On
ProxyPass        "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/"
RequestHeader set X-Forwarded-Proto "https"

Use HTTP rather than AJP unless you have a reason; the Tomcat page explains the AJP trade-offs and the Ghostcat vulnerability.

Several apps under one host name

ProxyPass        "/api/" "http://127.0.0.1:8000/"
ProxyPassReverse "/api/" "http://127.0.0.1:8000/"
ProxyPass        "/"     "http://127.0.0.1:3000/"
ProxyPassReverse "/"     "http://127.0.0.1:3000/"

Check that it works

curl -sI https://app.example.com/ | head -5
curl -s -o /dev/null -w '%{http_code}\n' 'https://app.example.com/socket.io/?EIO=4&transport=polling'

A 200, or the redirect your app normally sends, shows the proxy works. Look in the app’s own log to confirm that it sees the right Host, client IP and X-Forwarded-Proto. For WebSockets, the browser’s developer tools show the connection with status 101.

Common mistakes

To spread traffic over several backends, continue with load balancing. Error codes such as 502 and 503 are covered in fixing common errors.

Sources