Apache reverse proxy

The Apache vhost directives Bicrypto needs — proxy, WebSocket upgrade, HTTP/2 and compression — and the modules that must be loaded for them to work.

4 min readUpdated 3 August 2026apache, proxy, websockets, virtualmin

Apache is the default. The installer detects it first, and if apache2 or httpd is running it configures Apache and never looks at nginx. A Virtualmin box — the setup most installs use — is Apache out of the box.

Two processes need to be reachable through one hostname:

Path Goes to Why
/api localhost:4000 the backend, including every WebSocket
everything else localhost:3000 the Next.js frontend

Enable the modules first

The directives below are inert without these. Newly enabled modules only load on a restart — a reload will not do it.

a2enmod proxy proxy_http proxy_wstunnel rewrite deflate http2 headers
systemctl restart apache2

On RHEL, CentOS and AlmaLinux the modules are normally compiled in and loaded from /etc/httpd/conf.modules.d. Check rather than assume:

for m in proxy proxy_http proxy_wstunnel rewrite deflate http2 headers; do
  apachectl -M 2>/dev/null | grep -q "${m}_module" || echo "MISSING: $m"
done

headers is needed for one line in the vhost below, and that line is a security control rather than a nicety — see Visitor addresses.

proxy_wstunnel is the one that gets forgotten. Without it the pages render, and the charts, order book and balances never update — the WebSocket upgrade is answered with an HTML page instead of a socket.

The directives

Paste this inside every <VirtualHost> for the site — both :80 and :443. Put it after the DirectoryIndex line, at vhost level, never inside a <Directory> or <Location> block.

Protocols h2 http/1.1
ProxyPreserveHost On
KeepAlive On
KeepAliveTimeout 3
MaxKeepAliveRequests 500
ProxyTimeout 150
AddOutputFilterByType DEFLATE text/plain text/html text/xml text/css application/xml application/xhtml+xml application/rss+xml application/javascript application/x-javascript

# Discard any X-Forwarded-For the CLIENT sent. mod_proxy appends the real
# address to whatever arrives, so without this the backend receives
# "<whatever the caller typed>, <real client>". Requires mod_headers.
RequestHeader unset X-Forwarded-For

# Let certbot answer its own challenge instead of proxying it to Next.js.
ProxyPass /.well-known/acme-challenge !

ProxyPass /api/docs http://127.0.0.1:4000/api/docs
ProxyPassReverse /api/docs http://127.0.0.1:4000/api/docs

ProxyPass /api http://127.0.0.1:4000/api
ProxyPassReverse /api http://127.0.0.1:4000/api

# WebSockets. Must come BEFORE the catch-all ProxyPass below.
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/api/(.*) ws://127.0.0.1:4000/api/$1 [P,L]

ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/

<Proxy "http://127.0.0.1:4000/">
    ProxySet max=70000
</Proxy>

Then validate before reloading — a bad config will refuse to start and take the whole server down with it:

apache2ctl configtest && systemctl reload apache2

Why each line is there

The ! means "do not proxy this". Without it the catch-all sends certbot's challenge to Next.js, Next.js returns a 404 page, and certificate renewal fails silently three months after you stopped thinking about it.

Apache applies ProxyPass in order. If ProxyPass / appears first it matches the upgrade request too, and the socket never reaches port 4000. Every live price, chart candle and balance update in the platform travels over these sockets.

The connection pool to the backend. The default is small, and a busy exchange opens a long-lived socket per browser tab; when the pool is exhausted new requests queue behind old ones and the site feels frozen rather than broken.

Deliberately short. A trading front end opens many short requests plus a few long-lived sockets — holding idle keep-alive connections for the Apache default of 5 seconds ties up workers that the sockets need.

Long enough for the slowest admin exports and the market-data snapshot the charts request on first load. Too low and those come back as a 504 under load while everything else looks healthy.

HTTP/2 to the browser. The proxy hop to Next.js stays HTTP/1.1, which is what ProxyPass speaks — that is expected, not a misconfiguration.

localhost resolves through getaddrinfo, which on a dual-stack box may hand back ::1 or 127.0.0.1 depending on /etc/hosts ordering and the resolver's mood. Both work as a destination, but the backend also uses the address the connection arrived from to decide whether to believe a forwarding header, and two identical servers should not answer that question differently. Pinning the literal removes the variable.

Visitor addresses

One line in the vhost above is a security control:

RequestHeader unset X-Forwarded-For

Apache appends. mod_proxy adds the connecting address to whatever X-Forwarded-For the request already carried, rather than replacing it. So without this line, a caller who sends

X-Forwarded-For: 198.51.100.99

reaches the backend as 198.51.100.99, <their real address>. RequestHeader unset discards the client's copy first, so mod_proxy writes a single entry that the caller had no part in.

The backend reads the list right to left precisely so that a prepended forgery is ignored even without this line — but the line removes the ambiguity entirely, and costs nothing.

Forwarding headers are honoured automatically when the connection came from loopback, which is what a same-host Apache is. TRUST_PROXY exists only for a proxy on a different machine, and setting it when you do not need it is actively harmful: it makes the backend believe a forwarding header from any address, including a caller who reaches port 4000 directly.

If your proxy is on another host, list its network in TRUST_PROXY_CIDRS instead — that grants the trust to that network and to nothing else.

RemoteIPHeader X-Forwarded-For with no RemoteIPTrustedProxy / RemoteIPInternalProxy makes Apache adopt the client's own claimed address as %a and rewrite the header to a single entry containing it, with nothing of Apache's own appended. The result is a one-element list that the client authored end to end, which defeats reading from the right. RequestHeader unset is the directive you want here.

Upload size

Apache has no equivalent of nginx's 1 MB default, so uploads work without extra configuration. The platform's own cap is 5 MB (DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024), and that is what rejects an oversized KYC document or avatar — not the web server.

If a LimitRequestBody is set anywhere in your config, make sure it is above 5 MB or it will reject uploads the platform would have accepted.

Virtualmin

Virtualmin writes a vhost per domain and rewrites those files when you change settings in its UI, so directives can be lost on a later edit. Add the block through Virtualmin's own Edit Directives for the domain rather than by editing the .conf by hand, and re-check after any Virtualmin change that touches the website.

If you bought a managed install, this is applied for you: the install service runs an idempotent script that inserts the block after the DirectoryIndex line in every vhost, validates with configtest, reloads, and reverts every file it touched if the validation fails.

Verify

curl -sI https://example.com/            | head -1   # 200, from Next.js
curl -sI https://example.com/api/health  | head -1   # 200, from the backend

For the WebSocket, open the platform in a browser and watch a market page: if prices tick, proxy_wstunnel is loaded and the rewrite rule is ordered correctly. If the page renders but nothing moves, that pairing is what to check first — see Troubleshooting.

Using nginx instead? See Nginx. You need one or the other, not both.