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 |
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 10m;
# 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 10m;
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.
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 ceiling is 5 MB, applied to every route in body-parser.ts. No
route raises it. client_max_body_size 10m is not there to allow larger
uploads — it is there so nginx is never the component that rejects, because
nginx returns an HTML error page the frontend cannot parse, whereas the backend
returns a JSON 413 with a readable message.
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.
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:
-
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 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.
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.
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.
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.
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.
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.
Raise large_client_header_buffers. The session, CSRF and locale cookies
together exceed the 1k default request-header buffer.
/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 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.