Apache as a reverse proxy: ProxyPass, WebSockets and headers
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.
ProxyPassworks with it off. ProxyPass- Maps a URL path to a backend URL. Requests for
/orders?id=7go tohttp://127.0.0.1:3000/orders?id=7. ProxyPassReverse- Rewrites
Location,Content-LocationandURIheaders in the backend’s redirects. Without it, a redirect from the app may send the browser tohttp://127.0.0.1:3000/login. ProxyPreserveHost On- Passes the browser’s
Hostheader (app.example.com) instead of127.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
- Match the trailing slashes.
ProxyPass "/app/" "http://127.0.0.1:3000/"is right; mixing"/app"with".../"produces double or missing slashes. - Longest path first. ProxyPass rules are checked in order, so
/api/v2/must come before/api/. - Serve some paths from Apache. An exclamation mark excludes a path. It must come before the general rule:
ProxyPass "/static/" "!" ProxyPass "/.well-known/acme-challenge/" "!" ProxyPass "/" "http://127.0.0.1:3000/" Alias "/static/" "/var/www/app/static/" - Unix sockets (httpd 2.4.7 and later):
ProxyPass "/" "unix:/run/gunicorn/app.sock|http://localhost/".
Headers the backend receives
mod_proxy_http adds three headers by default (ProxyAddHeaders On):
| Header | Contains |
|---|---|
X-Forwarded-For | The client’s IP address |
X-Forwarded-Host | The Host header the client sent |
X-Forwarded-Server | The 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
ProxyTimeoutsets how long Apache waits for the backend; by default it equalsTimeout, which is 60 seconds.- Per backend you can set
connectiontimeout=5(time to open the connection) andtimeout=120(time to wait for data) on the ProxyPass line. retry=60is the default: after a connection error Apache stops sending requests to that backend for 60 seconds and answers 503 Service Unavailable instead. During development,retry=0retries straight away.- 502 Bad Gateway means the backend answered with something invalid or closed the connection; 504 Gateway Timeout means it did not answer in time. The error log shows which backend failed and why.
ProxyErrorOverride Onreplaces the backend’s error pages with Apache’s ownErrorDocumentpages, if you want one look for all errors.
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
- “No protocol handler was valid for the URL” in the error log means mod_proxy is loaded but mod_proxy_http (or the module for
fcgi://,h2c://and so on) is not. - Redirects to the backend address mean
ProxyPassReverseis missing or its URL does not match the backend’s. - Redirect loops between HTTP and HTTPS usually mean the app did not get or does not trust
X-Forwarded-Proto. - The backend port is open to the world. Bind the app to
127.0.0.1or a private network, or the proxy’s access rules can be bypassed. - ProxyPass inside
<Location>with two arguments. Inside a<Location>block, ProxyPass takes only the backend URL, because the path comes from the block.
To spread traffic over several backends, continue with load balancing. Error codes such as 502 and 503 are covered in fixing common errors.
Sources
- Reverse Proxy Guide, Apache httpd 2.4 documentation
- mod_proxy, Apache httpd 2.4 documentation
- mod_proxy_http, Apache httpd 2.4 documentation
- mod_proxy_wstunnel (deprecated), Apache httpd 2.4 documentation
- mod_http2 (H2WebSockets), Apache httpd 2.4 documentation
- mod_remoteip, Apache httpd 2.4 documentation
- mod_headers, Apache httpd 2.4 documentation
- core features (TimeOut), Apache httpd 2.4 documentation
- proxy_http.load in the Debian apache2 package, Debian Sources
- 00-proxy.conf in the Fedora httpd package, Fedora Package Sources
- 00-base.conf in the Fedora httpd package, Fedora Package Sources
- httpd_selinux(8) manual page (httpd_can_network_connect)