> ## Content Index
> Fetch the complete content index at: https://blog.diengdoh.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Apache as a Reverse Proxy for Docker Apps (and the Panel Trap)
- URL: https://blog.diengdoh.com/apache-reverse-proxy-docker-apps/
- Published: 2026-08-17T10:00:00.000Z
- Updated: 2026-10-11T10:16:27.000Z
- Description: Put Docker apps behind Apache: ProxyPass, ProxyPreserveHost, X-Forwarded-Proto, WebSockets, localhost-only ports, and why a panel gives you 302/404.
- Author: Lukas
- Tags: Docker, Linux

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

```yaml
# /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`:

```bash
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:

```bash
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:

```apache
<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 On`** passes the original `Host:` header through. Without it the app sees `Host: 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 for `https://`, sees an `http` request, 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 `//double` paths or missing ones.
- **`upgrade=websocket`** lets the same `ProxyPass` handle 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 separate `ProxyPass /ws ws://127.0.0.1:PORT/ws` line via `mod_proxy_wstunnel` instead.
- **`ProxyPassReverse`** rewrites `Location:` headers in redirects coming back from the app, so a `302 Location: http://127.0.0.1:2368/ghost/` becomes a proper public URL.

Test from the server itself first, bypassing DNS:

```bash
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](/lets-encrypt-fails-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:

```bash
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](/keyhelp-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 any `LimitRequestBody` in the panel config.
- **Timeouts.** Default `ProxyTimeout` follows Apache's `Timeout` (60s on most setups). For an app with long-running requests, add `ProxyTimeout 300` in the same directive box.
- **Real client IPs.** The container sees every request coming from `127.0.0.1`. Apache adds `X-Forwarded-For` automatically 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 in `wp-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](/multiple-php-versions-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](/nginx-reverse-proxy-setup/), and if you're choosing fresh, [Caddy vs nginx](/caddy-vs-nginx-auto-https/) 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.

---

### Related posts

- [Rebuilding My Ghost Blog After the Database Was Gone](https://blog.diengdoh.com/ghost-blog-rebuild-after-database-loss/)
- [The Idempotency Bug That Uploaded the Same File 153 Times](https://blog.diengdoh.com/idempotency-cron-automation-duplicate-uploads/)
- [Grafana vs Netdata vs Uptime Kuma — I Run All Three, Here's What Each Is Actually For](https://blog.diengdoh.com/grafana-vs-netdata-vs-uptime-kuma/)