Apache httpd 2.4 Reverse Proxy as a Trust Boundary
Use Apache httpd 2.4 with mod_proxy and mod_authz_core to terminate TLS and shield backend application servers from public networks.
02 Oct 2026, 05:34 UTC

Problem and takeaway
You need to expose an application server to the internet without letting clients reach it directly. The application runs on a private port, but nothing stops a packet from reaching that port if the host interface is open. A misconfigured proxy can also forward requests without access control, leak the backend address in redirects, or pass a forged client IP that the application trusts.
The practical takeaway is to run Apache httpd 2.4 as the only public entry point, terminate TLS there, forward to a loopback-only backend, enforce access control with mod_authz_core, and verify the boundary with config and network checks before and after reload.
Requirements
- Terminate TLS at the edge; clients connect to https only.
- Forward requests to the backend over loopback or a private subnet.
- Block direct client access to the backend port.
- Preserve the original client IP while marking it as untrusted application input.
- Keep edge access logs separate from backend logs.
Smallest suitable design
Assumptions: Apache httpd 2.4.x with mod_ssl, mod_proxy, mod_proxy_http, mod_authz_core, and mod_log_config installed. The backend listens on 127.0.0.1:8080. The public name is app.example.com.
Example virtual host, placed in /etc/httpd/conf.d/app.conf or /etc/apache2/sites-available/app.conf depending on the distribution:
<VirtualHost *:443>
ServerName app.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/certs/app.example.com.pem
SSLCertificateKeyFile /etc/ssl/private/app.example.com.key
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
<Location />
Require all granted
</Location>
CustomLog /var/log/httpd/app_access.log combined
ErrorLog /var/log/httpd/app_error.log
</VirtualHost>
ProxyPreserveHost On sends the original Host header to the backend. ProxyPassReverse rewrites Location and Content-Location headers from backend redirects so clients see the public name instead of 127.0.0.1:8080. Without these, backend routing and redirects can break.
The Require directive inside <Location /> applies to the public virtual host. It does not, by itself, protect the backend port. Protect the port by binding the backend to 127.0.0.1 only and by rejecting external packets to port 8080 with the host firewall, for example nftables or iptables. If the backend is another httpd instance, a separate <Location /> with Require ip 127.0.0.1 can reject non-loopback clients at the application layer.
Trust and data boundaries
The trust boundary ends at the httpd process. The public network is untrusted. The backend should treat httpd as the only client and should not accept connections from any other source. The application must treat X-Forwarded-For as untrusted unless httpd is the sole source of client connections and you have validated that configuration; a client that can reach the backend directly can forge the header.
Frontend HTTPS does not imply backend encryption. mod_proxy does not validate backend TLS by default. If the backend requires mutual TLS, add SSLProxyVerify and related directives; otherwise the backend connection is plain HTTP over loopback.
Operational checks
Run these on the httpd host. Most require root or sudo because they read protected logs, inspect sockets, or reload the service.
Before changing configuration
- Back up the existing virtual host file:
sudo cp /etc/httpd/conf.d/app.conf /etc/httpd/conf.d/app.conf.bak(path varies by distribution). - Run
sudo apachectl configtestorsudo apachectl -t. Expected output: Syntax OK. Risk: none; this is read-only. - Confirm required modules are loaded:
sudo apachectl -M | grep -E 'proxy_module|authz_core_module|ssl_module|log_config_module'. Expected: each module name appears. Missing modules mean the config will fail or behave unexpectedly after reload.
After reload
- Reload:
sudo apachectl gracefulorsudo systemctl reload httpd. Risk: active connections may be dropped depending on MPM and graceful behavior. - Test through the proxy:
curl -I --resolve app.example.com:443:127.0.0.1 https://app.example.com/. Expected: a backend response, typically 200 or 302, not an Apache 502 Proxy Error. - Verify the backend is not reachable directly from another host on the network:
curl -I --max-time 5 http://<httpd-host>:8080/. Expected: connection refused or timeout. From the local host, loopback access may still succeed because the backend is bound to 127.0.0.1; that is expected. - Confirm the backend socket is loopback-only:
sudo ss -ltnp | grep :8080. Expected: Local Address shows 127.0.0.1:8080, not 0.0.0.0:8080. - Inspect logs under load:
sudo tail -f /var/log/httpd/app_error.logwhile sending traffic. Look for proxy connection errors such as 502 upstream messages.
Failure modes
| Failure | Cause | Check |
|---|---|---|
| 502 Proxy Error | Backend is down or not listening on 127.0.0.1:8080 | curl the backend directly from the httpd host; check app_error.log |
| Proxy worker exhaustion | Slow backend responses consume all mod_proxy workers | Monitor error_log and worker status; consider event MPM and keepalive |
| Backend exposed to public internet | Missing Require rules, backend bound to 0.0.0.0, or firewall allows port 8080 | ss -ltnp; remote curl to port 8080; review authz directives |
| Redirects leak backend address | ProxyPassReverse omitted | curl -I through proxy and inspect Location headers |
| Forged client IP | Application trusts X-Forwarded-For while clients can reach the backend | Confirm backend accepts connections only from httpd |
Conditions that change the design
- WebSocket traffic: add mod_proxy_wstunnel and a separate ProxyPassMatch or ProxyPass for the WebSocket path.
- Backend mTLS: enable SSLProxyEngine and configure SSLProxyVerify, SSLProxyCACertificateFile, and SSLProxyProtocol.
- High concurrency: switch from prefork to event MPM and add keepalive=On to ProxyPass directives.
- Scale beyond a single host: replace the single httpd edge with a dedicated L7 load balancer or a proxy tier with health checks.
Limitations
- apachectl configtest validates syntax, not whether the backend is reachable or whether the firewall blocks port 8080.
- X-Forwarded-For is appended by mod_proxy and can contain multiple addresses; the rightmost address is not necessarily the original client.
- This design assumes a single edge instance. Multiple proxies behind a load balancer require consistent trust configuration for X-Forwarded-For and client IP validation.
- mod_proxy connection pooling can reuse backend connections; understand the timeout and keepalive behavior before tuning under load.
Rollback
Because editing the virtual host and reloading httpd changes runtime state, keep the backup made before the change. If the reload fails or traffic breaks, restore the backup and reload again:
sudo cp /etc/httpd/conf.d/app.conf.bak /etc/httpd/conf.d/app.conf
sudo apachectl configtest
sudo apachectl graceful
If configtest fails after restore, do not reload; fix the syntax first. A failed reload can leave the old process running or stop the service, depending on the init system.
Practical verification
The boundary is working when configtest passes, the required modules load, curl through the public name returns backend content, a remote host cannot reach port 8080, ss shows the backend bound to 127.0.0.1 only, and the access log records traffic under the public virtual host while direct backend attempts appear in the firewall or system logs, not in the application log.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.