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.

9 min readUpdated 6 September 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

An install that sets ECO_TRADING_ENABLED=1 adds a third proxy destination, the trading process on 4010, and needs a few more lines; see The trading process below, after the base directives are in.

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

ProxyPass /.well-known/acme-challenge !

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.

The WebSocket rule must precede the catch-all

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.

ProxySet max=70000

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.

KeepAliveTimeout 3

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.

ProxyTimeout 150

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.

Protocols h2 http/1.1

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.

127.0.0.1, not localhost

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.

The trading process (opt-in)

Skip this section unless .env has ECO_TRADING_ENABLED=1. With it, pnpm start adds a PM2 app named trading, the same backend entry point on ECO_TRADING_PORT (default 4010) with ECO_PROCESS_ROLE=trading. That process hosts the Ecosystem matching engine and serves the order, market, ticker and Hummingbot routes; the backend app on 4000 serves everything else and never holds the engine. What the split buys you, and how failover behaves, is in Two backend processes.

Nothing in the code forwards a request between the two processes. Apache has to send each path to the process that owns it. A trading path sent to 4000 by mistake does not fail loudly, which is the trap: a placement or a cancel is still accepted there and handed to the engine through the addon's cross-process path, but the order-book, ticker and order WebSocket streams on that socket stay silent, because the engine pushes those frames to the sockets of its own process. Four prefixes move; every other /api path, including all of /api/admin, stays on 4000.

Prefix What lives there
/api/ecosystem/order placement, cancel, cancel-all, OCO, the order list, and the customer's order WebSocket stream on the same path
/api/ecosystem/market the order-book WebSocket stream
/api/ecosystem/ticker the ticker WebSocket stream
/api/hb/ every Hummingbot door, HTTP and WebSocket

ProxyPass and ProxyPassMatch are matched in the order they appear and the first match wins, so the trading lines go above ProxyPass /api, and the trading WebSocket rewrite goes above the generic one. Paste this block between the /api/docs pair and the ProxyPass /api line, in every <VirtualHost> that carries the base directives:

# Trading process (ECO_TRADING_ENABLED). First match wins, so these precede /api.
# The regex keeps the boundary: /api/ecosystem/order and /api/ecosystem/order/...,
# never a hypothetical /api/ecosystem/orderbook.
ProxyPassMatch "^/api/ecosystem/(order|market|ticker)(/.*)?$" "http://127.0.0.1:4010/api/ecosystem/$1$2"
ProxyPassReverse /api/ecosystem/order  http://127.0.0.1:4010/api/ecosystem/order
ProxyPassReverse /api/ecosystem/market http://127.0.0.1:4010/api/ecosystem/market
ProxyPassReverse /api/ecosystem/ticker http://127.0.0.1:4010/api/ecosystem/ticker

ProxyPass        /api/hb/ http://127.0.0.1:4010/api/hb/
ProxyPassReverse /api/hb/ http://127.0.0.1:4010/api/hb/

# Trading WebSockets. Must come BEFORE the generic /api websocket rule.
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/api/ecosystem/(order|market|ticker)(/.*)?$ ws://127.0.0.1:4010/api/ecosystem/$1$2 [P,L]
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/api/hb/(.*) ws://127.0.0.1:4010/api/hb/$1 [P,L]

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

The generic ProxyPass /api, its ProxyPassReverse and the generic WebSocket rewrite stay exactly as they are; they now carry everything the four lines did not take. RewriteCond applies to the one RewriteRule that follows it, which is why it is written twice.

Three details worth knowing before you reload.

/api/hb/ moves whole. The Hummingbot key and account routes are bot management rather than trading, but the stream and console sockets under the same prefix need the engine, and splitting one prefix across two processes would need a regex with a list of exceptions that drifts from the code. /api/admin/hb is a different prefix and stays on 4000.

A second <Proxy> pool. ProxySet max is per backend URL. Without the second block the trading process gets mod_proxy's small default pool, and a busy bot fleet queues on it while 4000 sits idle.

Port 4010 is as private as 4000. The trading process binds every interface in production. Firewall it the same way and never hand the port to a browser; the platform's same-origin WebSocket URLs are built for 443 and only the proxy knows which process a path belongs to.

Validate and reload as above, then confirm from off the server with a probe that needs no login. /api/hb/ping is unauthenticated and lives on a moved prefix; /api/settings is unauthenticated and stays on 4000. Both print 200 while both processes are up. Then stop the trading app for a moment: the ping must turn 502 while settings stays 200. A ping that still answers 200 with the trading app stopped is reaching 4000, which means the block is below ProxyPass /api or in a vhost that was not edited.

for p in hb/ping settings; do
  echo "$p -> $(curl -s -o /dev/null -w '%{http_code}' https://example.com/api/$p)"
done
pm2 stop trading    # hb/ping -> 502, settings -> 200
pm2 start trading

On the server itself, GET /api/admin/ecosystem/engine/health asked on 127.0.0.1:4010 and on 127.0.0.1:4000 directly (an admin session is needed) reports a different pid and role for each; through the proxy that route always reaches 4000, because it is under /api/admin.

If the trading process is down, the four prefixes answer 502 while the rest of the site keeps working; that is the isolation the split exists for, and the fix is pm2 logs trading, not the vhost. If the trading process is up but the book and ticker streams are silent while orders still go through, the block is below ProxyPass /api and never matches.

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.

Public market-data endpoints

Seven unauthenticated endpoints publish this exchange's order books, trade tape and 24-hour figures in the shape CoinMarketCap and CoinGecko crawl. They are open by design and no admin setting gates them. If you are not seeking an aggregator listing, deny them in the vhost:

<LocationMatch "^/api/public/(summary|assets|tickers?|pairs)$">
    Require all denied
</LocationMatch>

<LocationMatch "^/api/public/(orderbook|trades)(/|$)">
    Require all denied
</LocationMatch>

Require is authorization, so Apache answers 403 during access control and the request never reaches the backend. That is the reason to block here rather than in the application: GET requests are not rate limited on this platform — the limiter covers only POST, PUT, PATCH and DELETE — so a crawler that keeps polling a disabled endpoint would still cost the backend a connection on every hit, for as long as it cares to.

Three things make a hand-written block load cleanly and deny nothing.

.htaccess is never read for these paths. A proxied URL is not mapped to a directory on disk, so Apache enters no <Directory> context and consults no .htaccess inside one. The block has to be in the vhost.

A RewriteRule pattern needs its leading slash here. In vhost context the pattern is matched against the URL-path with the slash still on it, so the ^api/public/summary$ form — which is correct inside .htaccess, where the per-directory prefix is stripped — never matches. <LocationMatch> has no equivalent trap, and it also stays clear of the WebSocket RewriteRule above, where ordering is load-bearing.

ticker and tickers are two separate endpoints. Both spellings are served, because CoinMarketCap's specification names the singular while a crawler builds the path itself. The tickers? above covers both; a block naming only one leaves the other open.

/api/public/referrer/{code} sits under the same prefix and is what every referral link you have issued resolves against. A prefix block on /api/public takes it down with the market data.

Confirm the result from off the server rather than from the config. Every line must print 403:

for p in summary assets pairs tickers ticker orderbook/BTC_USDT trades/BTC_USDT; do
  echo "$p -> $(curl -s -o /dev/null -w '%{http_code}' https://example.com/api/public/$p)"
done

Then check the referral lookup still answers. Anything other than 200 or 404 here means the pattern is too broad:

curl -s -o /dev/null -w '%{http_code}' https://example.com/api/public/referrer/TEST; echo

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.