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.
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 apache2On 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"
doneheaders 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 apache2Why 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-ForApache 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.99reaches 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 backendFor 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.