Nginx and reverse proxy

Complete nginx server blocks for Bicrypto, if you run nginx instead of the default Apache.

15 min readUpdated 6 September 2026nginx, proxy, websockets, ssl

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.

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 -1

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, 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)"
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

Client 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:

  1. Install certbot — the nginx plugin edits your server block in place.

    apt install certbot python3-certbot-nginx
  2. 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
  3. 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 start

pnpm 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.