Nginx and reverse proxy

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

10 min readUpdated 3 August 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

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.

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:

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