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 |
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 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
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 tradingOn 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-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.
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)"
doneThen 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; echoUpload 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.