Apache as a Reverse Proxy for Docker Apps (and the Panel Trap)
Put Docker apps behind Apache: ProxyPass, ProxyPreserveHost, X-Forwarded-Proto, WebSockets, localhost-only ports, and why a panel gives you 302/404.
My web server is a hosting panel box. Apache in front, PHP sites as normal, mail, DNS, the usual. But a handful of things on it are Docker stacks: a Ghost blog, a couple of WordPress containers with their own MariaDB, a small Node app. They all sit behind the same Apache, on the same ports 80 and 443, with the panel handling Let's Encrypt.
The config for that is five lines. Getting those five lines into the right vhost on a panel cost me more time than writing them.
TL;DR: Containers bind to 127.0.0.1:PORT only. The HTTPS vhost gets ProxyPreserveHost On, RequestHeader set X-Forwarded-Proto "https", ProxyPass / http://127.0.0.1:PORT/ (with upgrade=websocket if the app needs it) and ProxyPassReverse. Enable proxy, proxy_http, headers (and proxy_wstunnel on older Apache). On a panel, the directives go in the HTTPS field, and the HTTPS vhost only exists once a certificate does.
The container side: localhost only
# /opt/blog/docker-compose.yml
services:
ghost:
image: ghost:6
restart: unless-stopped
ports:
- "127.0.0.1:2368:2368"
environment:
url: https://blog.example.com
volumes:
- ./content:/var/lib/ghost/content
The 127.0.0.1: prefix is the important part. Without it, Docker publishes on 0.0.0.0 and inserts its own iptables rules, which means the port is reachable from the internet even if your host firewall says otherwise. Docker's NAT rules are evaluated before most host firewall setups ever see the packet. Bind to localhost and the only way in is through Apache.
Check after every up -d:
ss -tlnp | grep -E '2368|8091'
# want: 127.0.0.1:2368, not 0.0.0.0:2368 or [::]:2368
One app, one port. I keep a little list of which stack owns which port in the compose files' directory, because "was 8093 the staging copy or the other one" gets old fast.
The Apache side
Modules first:
a2enmod proxy proxy_http headers
a2enmod proxy_wstunnel # only needed on Apache < 2.4.47, harmless otherwise
apachectl configtest && systemctl reload apache2
Then the vhost. Here's the full plain-Apache version, without a panel:
<VirtualHost *:443>
ServerName blog.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/blog.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/blog.example.com/privkey.pem
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:2368/ upgrade=websocket
ProxyPassReverse / http://127.0.0.1:2368/
</VirtualHost>
<VirtualHost *:80>
ServerName blog.example.com
Redirect permanent / https://blog.example.com/
</VirtualHost>
What each line is for:
ProxyPreserveHost Onpasses the originalHost:header through. Without it the app seesHost: 127.0.0.1:2368, builds links and redirects with that, and you get absolute URLs pointing at localhost. WordPress in particular gets very confused.RequestHeader set X-Forwarded-Proto "https"tells the app the visitor is on HTTPS even though Apache talks plain HTTP to the container. This is the line that prevents the classic redirect loop: the app is configured forhttps://, sees anhttprequest, redirects to https, Apache proxies that as http again, forever. Ghost and the official WordPress image both honour this header.ProxyPass / http://127.0.0.1:2368/forwards everything. Keep the trailing slashes consistent on both sides; mismatched slashes produce//doublepaths or missing ones.upgrade=websocketlets the sameProxyPasshandle WebSocket upgrades (Apache 2.4.47+). Anything with live updates (dashboards, chat, some Node apps) breaks without it. On older Apache you'd add a separateProxyPass /ws ws://127.0.0.1:PORT/wsline viamod_proxy_wstunnelinstead.ProxyPassReverserewritesLocation:headers in redirects coming back from the app, so a302 Location: http://127.0.0.1:2368/ghost/becomes a proper public URL.
Test from the server itself first, bypassing DNS:
curl -sI http://127.0.0.1:2368/ | head -3 # container answers?
curl -sI --resolve blog.example.com:443:127.0.0.1 https://blog.example.com/ | head -5
The first checks the app, the second checks Apache's vhost, and if the first is fine and the second isn't, the problem is entirely in Apache.
On a panel: where the lines go
Panels (Plesk, KeyHelp, and the rest) don't want you editing the vhost files directly: they regenerate them and your changes vanish. Instead each domain has an "additional Apache directives" box, and the panel includes that text into the vhost it generates. In my case the panel writes it to a separate include file per domain and per protocol, something like custom_vhosts/<user>_<domain>_https.conf.
Note the _https in that name. There are two boxes: one for HTTP, one for HTTPS.
Gotcha: directives in the HTTPS field, and no cert means no HTTPS vhost
Put the proxy lines into the HTTPS directives field. The HTTP vhost should keep doing what the panel does with it: redirect to HTTPS and answer Let's Encrypt's /.well-known/acme-challenge/ requests. If you also proxy port 80 to the container, the container gets the ACME challenges and the certificate renewal may fail.
And now the trap. My panel only generates the HTTPS vhost once the domain has a certificate. No cert, no HTTPS vhost, so your carefully pasted proxy directives aren't included anywhere. What you see from outside is the panel's default behaviour for a domain without content: a 302 redirect on HTTP and a 404 or the default page on HTTPS. Not an Apache error, not a proxy error, nothing in the container logs, because nothing ever reaches the container.
I hit this when moving my blog to that box. The cert couldn't be issued yet because DNS still pointed partly at the old server (that saga is in Let's Encrypt fails with a duplicate A record), so for a while the new box answered only 302/404 for the blog no matter what I put in the directive box. Once DNS was fixed and the panel issued the certificate, the HTTPS vhost appeared with the proxy lines in it and the blog answered immediately.
How to check whether you're in this state:
grep -rli "<domain>" /etc/apache2/ | grep -i https # is there an https include at all?
apachectl -S 2>/dev/null | grep -i <domain> # does Apache know a :443 vhost for it?
grep -rl "ProxyPass" /etc/apache2/ | xargs grep -l <domain> # are your lines actually loaded?
If there's only a :80 entry, fix the certificate first. Everything else is a distraction.
Gotcha: the panel firewall can eat Docker's rules
Some panels reload their firewall with iptables-restore, which replaces the whole filter table, including the chains Docker created. Containers keep running but lose networking, and Apache gets 503 Service Unavailable or "connection refused" from the backend. systemctl restart docker brings the chains back. Full story and the longer-term fix: panel firewall breaks Docker.
A few more practical notes
- Big uploads. Apache's proxy doesn't impose a body limit by default, but the app might. Ghost image uploads and WordPress media go through fine with this setup; if you get
413, look at the app or at anyLimitRequestBodyin the panel config. - Timeouts. Default
ProxyTimeoutfollows Apache'sTimeout(60s on most setups). For an app with long-running requests, addProxyTimeout 300in the same directive box. - Real client IPs. The container sees every request coming from
127.0.0.1. Apache addsX-Forwarded-Forautomatically when proxying; tell the app to trust it if you care about IPs in its logs or rate limits (for WordPress that means a plugin or a small snippet inwp-config.php; for other apps, check their "behind a proxy" docs). - Don't proxy what you don't have to. A plain WordPress runs perfectly well on the panel's own PHP-FPM. I only containerised the ones that came over as complete Docker stacks with their own database. If you're juggling PHP versions instead, see multiple PHP versions on one server.
If your front door is nginx instead of Apache, the same ideas map almost one to one: proxy_set_header Host, X-Forwarded-Proto, proxy_pass, plus the Upgrade/Connection headers for WebSockets. That version is in my nginx reverse proxy setup, and if you're choosing fresh, Caddy vs nginx is worth a look, since Caddy does the certificate part for you.
Five lines of Apache. Just make sure they land in a vhost that exists.