Nginx and reverse proxy
Complete nginx server blocks for Bicrypto, if you run nginx instead of the default Apache.
Most installs run Apache — the installer detects it first, and Virtualmin uses it by default. If that is you, read Apache instead. This page is for installs that run nginx.
The installer never writes an nginx config. configure_nginx() in installer.sh
runs systemctl restart nginx and nothing else, so the entire proxy layer is
yours to write. Until you do, the site is a Next.js server on port 3000 with no
API behind it.
What is listening
pnpm start brings up three PM2 processes from production.config.js.
| Process | Port | Where the port comes from | Who should reach it |
|---|---|---|---|
frontend |
3000 | Hardcoded PORT: 3000 in the app's env block |
nginx only |
backend |
4000 | NEXT_PUBLIC_BACKEND_PORT, default 4000 |
nginx only |
cron |
4001 | Pinned in production.config.js |
nobody |
trading |
4010 | ECO_TRADING_PORT; only with ECO_TRADING_ENABLED=1 |
nginx only |
The fourth row is an opt-in. Without ECO_TRADING_ENABLED there is no
trading app and the generic /api/ block below is the whole story; with it,
four path prefixes move to port 4010 and need the locations described under
The trading process.
The frontend port is not configurable. PM2's per-app env block overrides the
process environment, so NEXT_PUBLIC_FRONTEND_PORT in .env does not move it —
scripts/pm2-lifecycle.js hardcodes frontendPort = () => 3000 for exactly this
reason. If you change the backend port in .env, change it in nginx too.
Port 4001 exists only so the cron worker does not collide with the backend on bind. It serves no traffic. Never point a proxy or load balancer at it.
configure_security() runs ufw allow 3000 (or firewall-cmd --add-port=3000/tcp).
That was for pre-proxy testing. Once nginx is in front, close it — otherwise
visitors can reach the app on http://your-server:3000, bypassing TLS, and the
Secure cookies the backend sets will never be accepted on that origin.
Ports 4000 and 4001 are never opened by the installer, but the backend binds every interface in production, so confirm your firewall actually blocks them.
Why the API needs its own location block
In development, Next.js proxies /api, /uploads and /img/logo to the
backend. In production it does not. frontend/next.config.js returns early
before those rewrites are defined whenever NODE_ENV is not development.
The consequence: without a location /api/ block in nginx, every REST call and
every WebSocket in the product returns the Next.js 404 page. The site renders,
the login form appears, and nothing works.
The server block
Replace example.com and the certificate paths. Everything else is sized for
this platform.
# Must live at http{} level, not inside server{}. Include it from nginx.conf
# or from /etc/nginx/conf.d/upgrade-map.conf.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream bicrypto_frontend {
server 127.0.0.1:3000;
keepalive 32;
}
upstream bicrypto_backend {
server 127.0.0.1:4000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Keep ACME above the redirect or certificate renewal fails silently.
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
root /var/www/html;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# The backend rejects bodies over 5 MB itself with a JSON 413. Leave
# headroom so that JSON is what the client sees, not an nginx HTML page.
client_max_body_size 40m;
# accessToken, sessionId and csrfToken are JWT-sized. A few of them plus a
# locale cookie overruns the 1k default and nginx answers 400.
client_header_buffer_size 16k;
large_client_header_buffers 8 64k;
# The backend sets these on JSON responses only. Next sets none, so page
# responses are unprotected unless nginx adds them here.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# REST and every WebSocket in the product.
location /api/ {
proxy_pass http://bicrypto_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Login responses carry three Set-Cookie headers plus five security
# headers, which can overflow the 4k default into a 502.
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Runtime uploads. These must go to the backend — see the note below.
location /uploads/ {
proxy_pass http://bicrypto_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Pages, /_next/*, static assets.
location / {
proxy_pass http://bicrypto_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}# Virtualmin, cPanel and Plesk generate the server block for you and only let
# you add directives inside it. A `map` cannot go here, so hardcode the
# Connection header on /api/ instead. It costs upstream keepalive on that
# location; nothing else.
client_max_body_size 40m;
client_header_buffer_size 16k;
large_client_header_buffers 8 64k;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ACME first, or renewal breaks the moment the catch-all below is added.
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
root /home/USER/public_html; # this site's real document root
try_files $uri =404;
}
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /uploads/ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}Two details that look cosmetic and are not.
Use 127.0.0.1, not localhost. On a dual-stack box localhost may resolve
to ::1 first, and the backend is not guaranteed to be listening there. The rest
of the codebase uses 127.0.0.1 for internal calls for the same reason.
Keep the trailing slash on location /api/. A bare location /api is a
prefix match, so it also captures any path that merely starts with those four
characters and hands it to the backend, which answers 404.
WebSockets
This is a trading platform. Live prices, the order book, order fills, deposit
detection, P2P chat and the admin cron console are all WebSockets. There are 28
socket endpoints and every one of them lives under /api. A proxy that drops
upgrades does not produce an error message — it produces charts that never
populate and an order form that never confirms.
In production the browser connects to wss://<your domain>/api/... on port 443,
same origin, no port number. Everything therefore rides through the location /api/ block above, which is why the Upgrade and Connection headers and
proxy_http_version 1.1 are on it. Next.js serves no WebSockets in production,
so location / does not need them.
Timeouts must clear the heartbeat
The backend pings every connected socket every 30 seconds and closes anything
that has not answered after roughly one and a half intervals. uWebSockets' own
idle timeout is 120 seconds. So proxy_read_timeout has to comfortably exceed
30 seconds; the 300s above gives plenty of margin. Anything at or below 60s will
cut healthy sockets on quiet markets.
The browser WebSocket manager retries five times with exponential backoff capped at 30 seconds, then gives up and stops trying. It only starts again when the tab regains focus or the network comes back.
A proxy_read_timeout set too low does not cause a visible reconnect loop. It
burns the five retries during a quiet period, and from then on the user sits on
a frozen chart with no error on screen until they switch tabs.
Binary options connects to port 4000 directly
One store — the binary options order socket — builds its URL as
wss://<hostname>:4000/api/exchange/binary/order unless NEXT_PUBLIC_WS_URL is
set. nginx does not listen on 4000, so binary order updates never arrive on an
otherwise correct install. Everything else on the platform uses the same-origin
URL and is unaffected.
Set the override in .env:
NEXT_PUBLIC_WS_URL="wss://example.com"NEXT_PUBLIC_* values are inlined into the browser bundle at build time, so this
only takes effect after pnpm build:frontend. Editing .env and restarting PM2
changes nothing.
Note that the market and ticker services read a different variable,
NEXT_PUBLIC_WEBSOCKET_URL. Leave that one unset unless you are deliberately
terminating sockets somewhere other than the site origin.
Upgrades are cookie-authenticated with no origin check
The upgrade handler runs the geo gate, the rate limiter, authentication and the
role gate — but it never inspects Origin, and the auth cookies are
SameSite=None. If you want origin enforcement on sockets, nginx is the only
place to put it. Add this inside location /api/, before proxy_pass:
if ($http_upgrade = "websocket") {
set $ws_ok 0;
if ($http_origin = "https://example.com") { set $ws_ok 1; }
if ($http_origin = "https://www.example.com") { set $ws_ok 1; }
if ($ws_ok = 0) { return 403; }
}List every origin your users actually load the site from. Miss one and those users lose all live data.
The trading process (opt-in)
Skip this section unless .env has ECO_TRADING_ENABLED=1. With it,
pnpm start brings up a fourth PM2 app, trading, running 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. See Two backend processes
for what the split buys you and how failover behaves.
Nothing in the code forwards a request from one process to the other. The split is a proxy property: nginx sends each path to the process that owns it. A trading path sent to the web process 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, a little later than it would have been, 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. So the proxy config is part of enabling the feature, not an afterthought.
Four prefixes move to the trading process. Every other /api/ path, including
all of /api/admin/ and the Ecosystem wallet, deposit, withdraw, token and
chart routes, 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, which shares the HTTP path |
/api/ecosystem/market |
the order-book WebSocket stream, pushed by the engine that owns the book |
/api/ecosystem/ticker |
the ticker WebSocket stream, computed from the engine's resident books |
/api/hb/ |
every Hummingbot door: order, orderbook, account, exchange-info, keys, connector, console, perpetual, ping, setup, strategy, ticker, time, trades, and the stream, console and agent sockets; the whole prefix moves, so a route added under it later moves with it |
Add a second upstream next to bicrypto_backend, and four ^~ locations
inside the server block. Put them above the generic location /api/; the
order does not decide the match (nginx picks the longest matching prefix, and
^~ stops it from consulting regex locations afterwards), but keeping them
together makes the intent obvious to the next person who edits the file.
upstream bicrypto_trading {
server 127.0.0.1:4010;
keepalive 32;
}
# Inside server { }, before the generic /api/ location.
location ^~ /api/ecosystem/order { include /etc/nginx/snippets/bicrypto-trading.conf; }
location ^~ /api/ecosystem/market { include /etc/nginx/snippets/bicrypto-trading.conf; }
location ^~ /api/ecosystem/ticker { include /etc/nginx/snippets/bicrypto-trading.conf; }
location ^~ /api/hb/ { include /etc/nginx/snippets/bicrypto-trading.conf; }The snippet is the body of your location /api/ block with only the upstream
changed. That is the invariant to preserve when you edit either one: same
headers, same buffers, same timeouts, different target. Three of the four
prefixes carry WebSockets, so the Upgrade and Connection lines are not
optional here.
proxy_pass http://bicrypto_trading;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;On a panel-managed vhost there is no upstream and no map, so the snippet
uses proxy_pass http://127.0.0.1:4010; and
proxy_set_header Connection "upgrade";, exactly as the panel variant of the
/api/ block above does. Snippets cannot always be included from a panel's
per-domain directives; pasting the body into each of the four locations works
the same.
Three details that decide whether the split works.
^~ and no trailing slash on the three ecosystem prefixes. location ^~ /api/ecosystem/order is a prefix match, so it also captures the order list at
/api/ecosystem/order itself and the socket at the same path, which a
/api/ecosystem/order/ form would miss. It would equally capture a hypothetical
/api/ecosystem/orderbook; no such route exists today (the Ecosystem routes
are chart, deposit, market, order, ticker, token, wallet and withdraw), and if
one is ever added the location must become
location ~ ^/api/ecosystem/order(/|$) to keep the boundary.
/api/hb/ keeps its slash, and 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 needs regex locations. The whole prefix goes to 4010.
/api/admin/hb/ is a different prefix and stays on 4000.
Port 4010 is as private as 4000. The trading process binds every interface in production like the web process does. Firewall it the same way, and never point a browser at it: the platform's same-origin WebSocket URLs are built for port 443, and only the proxy knows which process a path belongs to.
Confirm the affinity 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. If the ping still answers 200
with the trading app stopped, the prefix is reaching 4000 and the locations
are not matching.
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
# A socket on a moved prefix still upgrades through the proxy: expect 101.
curl -i -N -o - -s \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/api/ecosystem/ticker | head -1On 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, requests on 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 proxy. If the trading process is up
but the book and ticker streams are silent while orders still go through, the
four prefixes are answering from 4000: the locations are missing or a regex
location placed later in the file is winning; ^~ is what prevents the
second.
Uploads
location /uploads/ must proxy to the backend on 4000. It is tempting to let
location / handle it, because the files live under frontend/public/uploads/
and Next serves public/. That fails.
next start indexes the public/ directory once, when the process boots.
Anything written there afterwards is not in the index and returns 404 until the
frontend restarts. Every runtime upload — KYC documents, avatars, product
images, P2P dispute evidence — is written after boot. The backend serves the same
directory by reading from disk per request, so routing /uploads/ there is what
makes freshly uploaded files visible.
Logo uploads are the exception and need no special handling: they overwrite existing files at fixed paths that were already present at build time.
Body size
The platform floor is 5 MB, applied in body-parser.ts to every route that does
not say otherwise. A handful do say otherwise, because they carry a file as a
base64 data URL inside JSON — which is about 1.37x the file:
| Route | maxBodyBytes |
What it carries |
|---|---|---|
POST /api/upload, /api/upload/heic, /api/upload/kyc-document |
14 MB | images, KYC documents |
POST /api/p2p/trade/{id}/dispute/evidence |
36 MB | a 25 MB screen recording or a 20 MB PDF |
POST /api/p2p/trade/{id}/message/upload |
8 MB | a 5 MB chat image |
client_max_body_size must sit above the largest of these, or nginx refuses
first — and nginx returns an HTML error page the frontend cannot parse, whereas
the backend returns a readable JSON 413. 10m was correct when nothing rose
above the floor; it now silently caps dispute evidence at about 7 MB of video,
which presents as an upload that spins and then fails rather than as a limit.
client_max_body_size 40m;Set it once at http/server level as above, or scope it to the API if you
would rather keep the rest of the site tight:
location /api/ {
client_max_body_size 40m;
# …proxy_pass as below
}Compression
You can leave gzip at its default. Both upstreams already compress their own
output: the backend gzips JSON responses over 1 KB and stamps Content-Encoding
on every response (identity when it skipped compression), and Next.js
compresses page and asset responses because compress is left at its default of
true. nginx will not re-compress a response that already carries a
Content-Encoding header, so gzip on; has nothing to act on for proxied
traffic.
It is still worth enabling for anything nginx serves from disk itself — ACME challenges, custom error pages:
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json
application/javascript application/xml+rss;Do not add gzip_proxied any expecting it to compress backend responses. That
directive governs requests that arrive at nginx carrying a Via header, which
is a different situation, and it still cannot touch a response that is already
encoded.
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 server block:
location = /api/public/summary { return 403; }
location = /api/public/assets { return 403; }
location = /api/public/pairs { return 403; }
location = /api/public/tickers { return 403; }
location = /api/public/ticker { return 403; }
location ^~ /api/public/orderbook/ { return 403; }
location ^~ /api/public/trades/ { return 403; }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. nginx answers these without opening one.
The modifiers are doing real work. = is an exact match and outranks every
prefix and regex location in the file regardless of where it appears, and ^~
tells nginx to stop at the prefix instead of going on to try regex locations.
Plain prefixes would work today — they are longer than /api/ and would win
that contest — but they lose to any regex location, so one added later would
silently reopen the endpoints.
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. A block naming only one leaves the other open.
/api/public/referrer/{code} sits under it and is what every referral link you
have issued resolves against. Deny the seven paths individually.
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; echoClient IP
There is nothing to set. nginx on this machine connects to the backend over
loopback, and a request that arrives from loopback is one the backend trusts to
carry a forwarding header. The proxy_set_header X-Forwarded-For $remote_addr;
line in the config above is the whole configuration.
TRUST_PROXY exists for one case only: a proxy on a different host. Setting
it when you do not need it is harmful rather than neutral — it tells the backend
to believe a forwarding header from any address, including a caller who
reaches port 4000 directly.
$proxy_add_x_forwarded_for appends to whatever the client sent, so a
request carrying a forged header reaches the backend as
<attacker's choice>, <real client>. $remote_addr discards the client's copy
and writes only the address nginx actually saw.
The backend reads the list right to left so a prepended forgery is ignored
either way — but that is a safety net, not a licence. Use $remote_addr, and
keep ports 3000 and 4000 firewalled regardless.
A proxy on another machine
List its network instead of enabling blanket trust:
TRUST_PROXY_CIDRS="10.0.0.0/8"That grants the trust to that network and nothing else. TRUST_PROXY="true" is
the blunt version — it accepts a forwarding header from any peer at all — and is
only appropriate when the API port is genuinely unreachable except through the
load balancer.
Behind Cloudflare or another CDN
Let nginx resolve the real address before the app ever sees it, so
$remote_addr is already correct and the rule above still holds:
# Refresh from https://www.cloudflare.com/ips/ — the ranges change.
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... remaining IPv4 and IPv6 ranges ...
real_ip_header CF-Connecting-IP;The backend also reads cf-connecting-ip and true-client-ip directly, ahead
of x-real-ip and x-forwarded-for, so a Cloudflare deployment resolves
correctly either way. Fixing it at the nginx layer is preferable because it also
corrects your access logs.
HTTPS is not optional
In production the backend marks accessToken and sessionId as Secure with
SameSite=None. Browsers discard Secure cookies delivered over plain HTTP,
and SameSite=None is invalid without Secure. A production install served on
HTTP cannot log anyone in — the credentials are accepted, the response is a 200,
and the session simply does not stick.
Nothing in the product automates certificates. Issue them yourself:
-
Install certbot — the nginx plugin edits your server block in place.
apt install certbot python3-certbot-nginx -
Issue the certificate — with the ACME location block already in place and nginx reloaded, so the challenge is served rather than proxied.
certbot --nginx -d example.com -d www.example.com -
Confirm renewal works — a dry run exercises the same path the timer will.
certbot renew --dry-run
Also make sure NEXT_PUBLIC_SITE_URL in .env is the https:// form of your
domain. It is the only source of the backend's CORS allowlist in production —
unset, the allowlist is empty. It is also inlined into the browser bundle and
into the next/image host allowlist at build time, so changing the domain
requires pnpm build:frontend, not just a restart.
Verify
# 1. Pages are reachable and TLS terminates.
curl -sI https://example.com/ | head -1
# 2. The API is proxied. Should be 200 with a JSON body, not a Next 404 page.
curl -s https://example.com/api/settings | head -c 200
# 3. WebSocket upgrades survive the proxy. Must print 101, not 200 or 502.
curl -i -N -o - -s \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/api/exchange/ticker | head -1
# 4. The app ports are NOT reachable directly.
curl -sS --max-time 5 http://example.com:3000/ ; echo "exit=$?"
curl -sS --max-time 5 http://example.com:4000/api/settings ; echo "exit=$?"/api/settings is unauthenticated and /api/exchange/ticker upgrades without a
session, so both checks work before you have any users. Steps 1 to 3 should
succeed; step 4 should fail to connect on both ports.
When something is wrong
The site loads but nothing has data
The location /api/ block is missing, is pointing at the wrong port, or sits
below location / where the catch-all wins. Run check 2 above: if it returns
HTML instead of JSON, the request is reaching Next.js.
Charts stay empty, everything else works
Upgrades are being dropped. Run check 3. A 200 means proxy_http_version 1.1
or the Upgrade/Connection headers are missing from location /api/. A 502
means the backend is not up on 4000 — check pm2 list.
Live data works for a while, then stops until the tab is refocused
proxy_read_timeout is too low for the 30-second heartbeat, and the client
exhausted its five reconnect attempts. Raise it to 300s and reload nginx.
Binary options orders never update
Expected until NEXT_PUBLIC_WS_URL is set and the frontend is rebuilt. That
store connects to port 4000 directly. See the WebSockets section above.
Login succeeds but the user is immediately signed out again
The site is being served over HTTP, or over HTTPS with mixed-origin access such
as http://server-ip:3000. The session cookies are Secure; the browser is
discarding them. Close port 3000 and force HTTPS.
502 with "upstream sent too big header" in the error log
Raise proxy_buffer_size and proxy_buffers on location /api/. Login
responses carry three Set-Cookie headers plus the backend's five security
headers, which overflows nginx's 4k default.
400 "Request Header Or Cookie Too Large"
Raise large_client_header_buffers. The session, CSRF and locale cookies
together exceed the 1k default request-header buffer.
Uploaded images 404 but older ones load
/uploads/ is being served by Next.js instead of the backend. Next indexes
public/ at boot; files written after that are invisible to it. Add the
location /uploads/ block.
The order book and ticker streams are silent, everything else works
Only on an install with ECO_TRADING_ENABLED=1. Either the trading app is
down (pm2 list; the four prefixes answer 502) or the four ^~ locations
were never added, so the trading paths are reaching the web process: orders
still go through, handed to the engine by the addon's cross-process path, but
the streams are pushed only to sockets of the process that hosts the engine.
See The trading process.
Certificate renewal fails after the proxy went in
The location / catch-all is swallowing the ACME challenge. The
location ^~ /.well-known/acme-challenge/ block must appear in the server that
listens on port 80, above the redirect, with root set to the real document
root.
After a domain change
Changing the domain is not an nginx-only edit. NEXT_PUBLIC_SITE_URL is baked
into the browser bundle and the image host allowlist at build time, so a stale
value means the browser calls the old origin and next/image rejects images on
the new host.
pnpm stop
# edit .env: NEXT_PUBLIC_SITE_URL, and NEXT_PUBLIC_WS_URL if you set it
pnpm build:frontend
pnpm startpnpm stop puts the maintenance server on ports 3000 and 4000, which answers
503 JSON for /api/* and a 503 HTML page for everything else. Your nginx config
needs no change for that to work — but do not add proxy_intercept_errors or a
custom error_page 503, or you will replace the maintenance page with your own.