Architecture
Last verified: 2026-07-31.
The shape of it#
browser
│
┌───────────┼────────────────────────────────┐
│ │ │
Cloudflare (direct, no proxy) (direct)
│ │ │
nginx datafeed.polkadex.ee polkadex-testnet.polkadex.ee
│ (chart candles, REST/UDF) (chain, wss via @polkadot/api)
│
Next standalone server ── systemd ── /opt/orderbook-fe
│
├─ /api/announcements reads /etc/orderbook-fe/announcements.json
└─ page render only
│
└── browser then talks to:
orderbook-api-test.polkadex.ee GraphQL (Apollo)
· HTTP queries + mutations
· WS subscriptions (graphql-ws)
The Next server is almost not in the data path. It renders pages and serves one route handler. Every piece of live data - markets, orderbook, balances, candles, chain state - is fetched by the browser, directly from the backends.
That single fact explains a lot:
- Server logs (
journalctl -u orderbook-fe) contain almost nothing about trading problems. Those errors are in the visitor's browser, which is what Sentry is for. - The datafeed sees each visitor's own IP, not the server's. When it logged one
constant
172.18.0.1for everyone, that was its own reverse proxy dropping the client address, not us proxying. - CORS preflights appear in datafeed logs because the calls really are cross-origin browser requests.
Data sources#
| What | Where from | How |
|---|---|---|
| Markets, orderbook, orders, balances, trades | GRAPHQL_URL |
Apollo over HTTP |
| Live orderbook / order / balance updates | GRAPHQL_WS_URL |
graphql-ws subscriptions |
| Chart candles | NEXT_PUBLIC_SERVER_BASE_URL |
REST, UDF format, browser → gateway |
| Chain state, extrinsics, asset metadata | POLKADEX_CHAIN |
@polkadot/api over wss |
| Bridge (EVM leg) | NEXT_PUBLIC_BRIDGE_SEPOLIA_RPC_URL |
wagmi/viem |
| Announcements | /api/announcements |
our own route handler → JSON file on disk |
AWS Amplify and AppSync are gone. The frontend used to speak AppSync's
protocol behind a USE_NEW_BACKEND flag; the flag, the SDK and ~800 lines of
legacy transport were removed. Everything is Apollo now. If you see /realtime
requested, that is a stale cached bundle.
Build-time vs runtime - the distinction that causes most confusion#
BUILD TIME (baked into the bundle, needs a rebuild to change)
every NEXT_PUBLIC_*
everything in next.config.js `env:` ← also inlined into the BROWSER bundle
generateBuildId
RUNTIME (change and restart, or not even that)
PORT / HOSTNAME / NODE_ENV install.sh manages these
/etc/orderbook-fe/announcements.json route handler reads per request
/etc/orderbook-fe/maintenance nginx checks per request
/etc/orderbook-fe/maintenance.html
Two consequences worth internalising:
The env: block is public. It is inlined into client JS exactly like a
NEXT_PUBLIC_* var, but without the prefix that makes that obvious. A
write-scoped Sentry token lived there and was served to every visitor. Never add
a credential.
Operational switches belong at runtime. Maintenance mode is an nginx flag
file, not the MAINTENACE_MODE env var - that var is read by src/proxy.ts,
which is Next middleware on the edge runtime and cannot read the filesystem,
and it would need a rebuild during an incident. nginx also keeps working when
Node is down, which is exactly when you need a maintenance page.
Deploy pipeline#
scripts/deploy.sh
1. git pull --ff-only
2. scripts/build-release.sh docker build → image
3. scripts/build-release.sh --tarball --from-image
docker cp /app out of the image → tarball
4. install.sh extract → /opt/orderbook-fe, systemd unit,
nginx vhost, env + announcements + maintenance page
5. health check against a real asset URL
Docker is a build sandbox, not the runtime. The image is built, the
standalone output is copied out, and systemd runs node server.js on the host.
There is no container in production. docker-compose.yml was deleted for this
reason - nothing ran it.
install.sh is idempotent and preserves operator state: an existing env file,
announcements.json and maintenance.html are never overwritten. Old installs
move to /opt/orderbook-fe.bak.<timestamp>; one is kept.
Why build-release.sh rather than plain docker build#
There are 59 build args and a missing one does not fail the build - it bakes
an empty string. NEXT_PUBLIC_PROJECT_ID empty means the app throws at boot.
The script exports the env file, derives args from the Dockerfile's ARG list,
and warns about every unset one.
It also syntax-checks the env file first. The file is sourced, so the shell
parses it - an apostrophe in a value (We'll notify you) is a syntax error that
otherwise aborts the build with a bare unexpected EOF.
Traps#
.dockerignore patterns are anchored at the context root. .env* excluded
./.env but not apps/hestia/.env, so the build env - Sentry DSN, faucet
key, gateway secret, a drpc key - was copied into the build context, carried into
Next's standalone output, and installed to /opt/orderbook-fe/apps/hestia/.env.
Fixed 2026-08-05, in this order, because the order matters: 25 NEXT_PUBLIC_*
vars had no Dockerfile ARG and reached the build only through that file, so
tightening the pattern first would have emptied all 25 silently. The ARGs went
in first, then the pattern became **/.env + **/.env.*.
The general rule: any new pattern here needs **/ if it should match at depth.
turbo.json's build.env list is a cache key, not documentation. The Docker
build runs npx turbo run build with --mount=type=cache,target=/app/.turbo, so
turbo's task cache survives between builds on the host. Turbo hashes only the env
vars named in build.env. A NEXT_PUBLIC_* missing from that list means changing
its value gives a cache hit and the previous bundle is reused - a rebuild that
appears to succeed and changes nothing.
20 vars were missing as of 2026-08-05 (all the bridge token addresses,
NEXT_PUBLIC_APP_URL, the Ybug and bridge-maintenance flags) and 9 removed ones
were still listed. The list is now generated from the Dockerfile's ARG lines;
keep it that way, and regenerate it whenever an ARG is added.
generateBuildId must be unique per build. It used to fall back to the
constant "orderbookDefaultId" because the Alpine builder has no git. Next
serves /_next/static/<buildId>/_buildManifest.js, that filename carries no
content hash, and both nginx and Cloudflare mark /_next/static/ immutable for a
year - so browsers served a stale manifest pointing at chunks that no longer
existed. Symptom: blank page on first load after a deploy, fine after a reload.
Cloudflare cache rules are zone-wide. Scope every rule with
http.host eq "…" or you affect every other subdomain.
Static imports ignore runtime guards. import * as Sentry with an
if (production) check inside the module still compiles the whole 99 MB
Sentry + OpenTelemetry tree into the graph. That made the root layout chunk slow
enough that dev first-loads timed out. Both instrumentation files import
dynamically now.
There are two env files, and they are not related. BUILD_ENV_FILE
(apps/hestia/.env, used by deploy.sh and build-release.sh) supplies
--build-arg values at build time. RUNTIME_ENV_FILE
(/etc/orderbook-fe/orderbook-fe.env, used by install.sh) is what systemd
loads via EnvironmentFile. Putting a NEXT_PUBLIC_* in the runtime file has no
effect whatsoever - it was already baked into the bundle.
Both were called ENV_FILE until 2026-08-05, and harden.sh does sed -i on
whichever it inherits. It only ever worked because install.sh is its only
caller. harden.sh now refuses to run that step if RUNTIME_ENV_FILE is unset.
deploy.conf is gitignored, so deployed hosts still set the old ENV_FILE.
deploy.sh honours it with a warning; rename the key and the shim goes away.
Conventions worth knowing#
Market.id vs Market.name. id is "{baseAssetId}-{quoteAssetId}"
("8-6"); name is "{baseTicker}/{quoteTicker}" ("WETH/USDT"). The GraphQL
backend keys on ids; the datafeed gateway keys on tickers. Passing the wrong one
gives a 404 from a remote service rather than a local error.
Asset decimals come from the chain, via assets.metadata, not from config.
Bridge config still holds EVM decimals (18 for WETH, 6 for USDC) and those are
correct for the Sepolia leg only - pallet_assets stores every bridged asset at
12dp. One decimals field cannot describe both legs of a bridge.
LANDING_PAGE is Market.name with non-alphanumerics stripped
("WETH/USDT" → WETHUSDT). Matching is a case-insensitive includes, so
WETHUSDT also matches PWETHUSDT; and getMarketUrl prefers a market saved in
localStorage over this value, so clear site data when testing a change.
Empty states are contextual. Orders panel tabs each describe what is missing, and distinguish "nothing yet" from "not connected". There is exactly one connect CTA on the trading screen, in the order form.
Known gaps#
Closed on 2026-08-05: the toFixed trailing-zero noise (now
@orderbook/format's formatDisplay, 25 tests), the Work Sans / colour-token
brand drift, and the unused useSubstrateWethBalance.
- The gateway serves only 4 of the chart's 8 resolutions; 15m/30m/4h/1W are
hidden in the toolbar. Backend side, see
BACKEND-CONTRACT.md. - The
__test__/suite at the repo root does not run and cannot. Six files import@polkadex/orderbook/..., a package scope that no longer exists, and some target providers deleted with Amplify.packages/corehas ajest.config.jsbut notestscript. Decide whether to port them to@mitra/*and the current provider paths, or delete them - either is better than a test directory nobody can execute.packages/formatis the one suite that runs today, viayarn test. packages/charthas notsconfig.json, so it is only ever type-checked transitively throughapps/hestia.tsc -p packages/chartis not possible.NEXT_PUBLIC_GATEWAY_SECRETships in the browser bundle. Routing chart calls through a Next route handler would fix that and remove the CORS preflights, but would make all chart traffic originate from one server IP.