Deploying OFE (apps/hestia) to a standalone server
Node 22 is a hard floor. @hyperbridge/sdk declares
engines.node ">=22.x.x" and yarn refuses to install on anything older. Node
20 also reached end of life in April 2026. The floor is pinned in five places
Dockerfile,.nvmrc,package.jsonengines,scripts/install.sh(NODE_MIN) andscripts/build-release.sh; move all five together.
The one thing that will bite you#
NEXT_PUBLIC_* values are baked in at build time.
They cannot be changed by restarting the container with a different env.
Changing one means rebuilding the image. They also ship to every browser -
never put a real secret in one. Everything else (GRAPHQL_URL,
POLKADEX_CHAIN, …) is read server-side and can vary per environment.
The same is true of everything in next.config.js's env: block, not just
the NEXT_PUBLIC_ prefix. Anything there that is referenced from client code
ends up in the browser bundle.
The @aksumite/* and @mitrabook/* libraries are consumed from npm - the
repo no longer vendors their source, so git clone && yarn install && yarn build is the whole story. The workspace packages that are built here
are packages/{core,chart,format,eslint-config,tsconfig}.
Start from apps/hestia/.env.example; it documents every key and what breaks
without it.
Deploying (one command)#
On the target host, once:
cp scripts/deploy.conf.example scripts/deploy.conf
$EDITOR scripts/deploy.conf # set DOMAIN, TLS mode, hardeningThereafter, every deploy:
sudo scripts/deploy.sh # or: sudo yarn deploy
sudo scripts/deploy.sh --dry-run # preview, changes nothingIt chains pull → build image → repack as tarball (from that image, so there is no second compile) → verify the artifact → install → health-check, and stops at the first failure.
The verification is the point. Running these steps by hand is how a payload
missing .next/static reached production: each step succeeded in isolation, the
app served HTML, and every JS chunk returned 400 - which looks like a proxy or
CDN fault, not a packaging one. deploy.sh counts the static JS files in the
tarball before the installer can overwrite a working install, then checks a
real asset URL afterwards, not just /.
Useful flags: --no-pull, --no-build (reuse the local image), --harden,
--no-harden, --plain-tls, --domain, --env, --keep-backups <n>,
--replace-env.
Hardening is not part of a routine deploy. It resets the firewall,
reinstalls fail2ban and rewrites sysctl, which is correct once on a new server
and wrong on every push. By default deploy.sh:
- skips it silently if
/etc/orderbook-fe/.hardenedexists (written byinstall.shwhen hardening last ran); - offers it once, interactively, if the host has never been hardened;
- skips it with a warning when there is no terminal, so CI never blocks on a prompt or reconfigures a firewall unattended.
--harden forces it; --no-harden suppresses both the action and the prompt.
The marker records what was applied, so keying off it is more accurate than
"is this the first deploy" - a host can be redeployed many times and still
never have been hardened.
scripts/deploy.conf is gitignored - it describes one host, not the project.
Is it idempotent?#
Re-running converges on the same end state - the install tree, systemd unit, nginx vhost and hardening files are all rewritten from scratch each time, so running twice leaves the same result as running once. Four caveats, all deliberate:
-
Old installs are moved aside, not deleted.
/opt/orderbook-febecomes/opt/orderbook-fe.bak.<timestamp>, and each is ~140 MB. Only the most recent is kept (--keep-backups N,0disables). Pruning runs early, at the moment the old tree is moved, so the newest backup is always retained even if the deploy fails afterwards - a rollback target survives every outcome.The log reports how much was reclaimed and the free space left on
/opt, because silent deletion of 140 MB directories gives no way to distinguish pruning from pruning being broken. To clear the lot right now without waiting for a deploy:sudo rm -rf /opt/orderbook-fe.bak.*Safe while the service is running - systemd executes from
/opt/orderbook-fe, not from a backup - but it leaves you with no rollback until the next deploy.
Announcements (no rebuild required)#
The in-app notification pane reads announcements from a JSON file on the server,
served by /api/announcements. Because that is a route handler it runs at
request time, so editing the file publishes or retracts an announcement with
no rebuild and no restart:
sudo nano /etc/orderbook-fe/announcements.jsonAn array of entries; see scripts/announcements.example.json:
[
{
"id": "trading-paused-undefined-arithmetic",
"category": "Announcements",
"type": "Attention",
"message": "Trading is paused",
"description": "Trading is temporarily paused while we fix an arithmetic error on the chain. Your balances are unaffected.",
"date": 1785369600000,
"active": true
}
]id- must be unique and must change when the message changes. It is the dismissal key in each user'slocalStorage, so reusing an id means anyone who dismissed the old announcement never sees the new one.type-Attention|Information|Error|Success|Loadingdate- epoch milliseconds; drives sort orderactive-true= unreadhref- optional link target
[] means nothing to announce, which is the state install.sh seeds. The file
is never overwritten by a deploy: it holds operational state, and it lives in
/etc/orderbook-fe rather than under /opt precisely because deploy.sh
replaces the install tree wholesale.
Malformed entries are skipped individually rather than failing the response, and
the reason is logged - a broken announcements file cannot take down the trading
UI. Check journalctl -u orderbook-fe if an announcement is not appearing:
[announcements] skipped "foo": "date" must be epoch milliseconds (a number)
Responses carry Cache-Control: max-age=60, so allow up to a minute plus your
Cloudflare TTL for a change to be visible.
Maintenance mode (no rebuild, no restart)#
sudo touch /etc/orderbook-fe/maintenance # site offline
sudo rm -f /etc/orderbook-fe/maintenance # site backTakes effect on the next request. nginx checks for the file per request; there is nothing to reload.
Edit the page itself at /etc/orderbook-fe/maintenance.html. It is written once
by install.sh and never overwritten, so wording survives deploys. It is
deliberately self-contained - no external CSS, fonts or images - because it has
to render when the app is down and its assets may be unreachable.
Why nginx and not the app. There is a MAINTENACE_MODE env var read by
src/proxy.ts, but it has two problems: proxy.ts is Next middleware running on
the edge runtime, which cannot read the filesystem, so no config file can
drive it - and it is baked in at build time, meaning a rebuild during an
incident. Worse, an in-app gate cannot answer at all once Node stops responding,
which is exactly when a maintenance page matters. The nginx check has neither
limitation.
Returns 503 with Retry-After: 300, not a 200 or a 404, so crawlers treat
the outage as temporary rather than de-indexing the site.
install.shrunsnginx -tbefore reloading, so a bad config is caught before it can take the site down. If you hand-edit the server block, runnginx -tyourself beforesystemctl reload nginx.
- The runtime env file is NOT replaced. An existing
/etc/orderbook-fe/orderbook-fe.envis kept so hand-edits on the server survive a redeploy. The consequence is that editing the source env and redeploying changes nothing: the installer now warns when the two differ. Use--replace-envto overwrite (the previous file is saved as.prev).NEXT_PUBLIC_*values are unaffected either way - they are compiled in at build time and need a rebuild. --hardenresets the firewall.ufw --force resetclears all rules and re-adds them, so there is a sub-second window with no firewall. The SSH rule is re-added before the firewall is re-enabled, so you will not be locked out, but do not run it over a link you cannot afford to lose.- Cloudflare IP ranges are re-fetched each run. Intentional - they change, and a stale list either blocks real visitors or leaves the origin open.
The service restarts on every run, so expect a few seconds of downtime. This is a single-host deploy, not a rolling one.
Building#
One script, two modes, one env file:
cp apps/hestia/.env.example apps/hestia/.env
$EDITOR apps/hestia/.env
scripts/build-release.sh # Docker image (DEFAULT)
scripts/build-release.sh --tarball # tarball for install.shyarn release runs the same script. Flags: --env <file>, --repo <name>,
--tag <tag>, --push, --platform <arch>, --install-docker, and
--skip-install (tarball mode only).
Docker on a fresh server. If Docker is missing, the script offers to
install it - prompting on a terminal, or non-interactively with
--install-docker (for CI). It adds Docker's own signed repository and
installs docker-ce plus the buildx and compose plugins, which the distro
docker.io / docker packages do not include and which --platform and
docker compose respectively require. Amazon Linux has no Docker CE repo, so
it uses the distro package and fetches the compose plugin separately.
Note docker in Debian/Ubuntu is an unrelated X11 dock applet - apt-get install docker installs the wrong thing. That is why this is scripted.
If you install Docker as a non-root user, the script adds you to the docker
group and stops: group membership only applies to a new login, so it can't
usefully continue in the current shell.
It warns about any Dockerfile ARG that has no value in your env file, so a
silently-empty build var shows up at build time rather than in production.
Build through the script, not
docker buildby hand. There are 59 build args, and a missingNEXT_PUBLIC_*does not fail the build - it bakes an empty string.NEXT_PUBLIC_PROJECT_IDis the sharpest case: empty, and the app throws at boot.There was a
docker-compose.ymlthat duplicated the arg list; it has been deleted. Nothing ran it (the deploy installs the extracted artifact under systemd, not a container), it drifted from the Dockerfile whenever anARGwas added, and compose only interpolates${VAR}from the shell or a root.env-env_file:applies to the running container, not to build args - so it silently baked empty values.
Option A - Release tarball + installer (plain VM, no Docker)#
Build a self-contained artifact on a build machine, ship it, install it. The installer handles Node, a service account, the systemd (or OpenRC) unit, and optionally an nginx reverse proxy.
scripts/build-release.sh --tarballIf you have already built the Docker image on that host, skip the second build entirely - the runner stage is the assembled standalone tree:
scripts/build-release.sh --tarball --from-image orderbook-fe:latestThat needs no Node, no yarn and no 4 GB rebuild; it extracts /app from the
image, verifies apps/hestia/server.js and .next/static are present, and
records the source image in the RELEASE file.
Either way you get dist/orderbook-fe-<version>-<sha>.tar.gz plus a
.sha256. Copy it to the server and run the bundled installer:
scp dist/orderbook-fe-*.tar.gz user@host:/tmp/
ssh user@host
cd /tmp && tar xzf orderbook-fe-*.tar.gz
sudo orderbook-fe/install.sh --domain orderbook.example.comSupported: Debian/Ubuntu/Raspbian, RHEL/CentOS/Rocky/Alma, Fedora, openSUSE/SLES, Arch/Manjaro, Amazon Linux 2 and 2023 (systemd), and Alpine (OpenRC). The installer detects the distribution, installs Node 22 if the system's is older, verifies the version it actually got (Arch and Alpine install "whatever is current", which may not be new enough), and refuses to run anywhere it can't identify a package manager.
Useful flags - --port, --user, --prefix, --env <file>, --with-nginx,
--no-start, and --dry-run to see every action without touching the host.
Re-running the installer upgrades in place: the previous install is moved to
/opt/orderbook-fe.bak.<timestamp> and an existing
/etc/orderbook-fe/orderbook-fe.env is preserved. Remove it all with
sudo /tmp/orderbook-fe/uninstall.sh --purge.
The tarball is environment-specific. NEXT_PUBLIC_* values are compiled
into the browser bundle at build time, so a build made with staging values
cannot be repointed at production by editing the env file on the server -
build once per environment. Everything else is read at runtime from
/etc/orderbook-fe/orderbook-fe.env.
Hardening#
The systemd sandbox is applied unconditionally: no capabilities, a seccomp
allow-list, ProtectSystem=strict, read-only application tree owned by root,
one writable path (.next/cache), and memory/process ceilings.
Host-level hardening is opt-in, because silently reconfiguring a firewall or SSH on someone's server is how people get locked out of it:
sudo orderbook-fe/install.sh --domain orderbook.example.com --harden--harden applies: kernel/sysctl network hardening (SYN cookies, no ICMP
redirects or source routing, kptr_restrict, ptrace_scope), a default-deny
firewall via ufw/firewalld/nftables allowing only 22/80/443, fail2ban with SSH
and nginx jails, automatic security updates, nginx rate limiting and security
headers, and - when a proxy is configured - rebinds the app to 127.0.0.1 so it
cannot be reached directly.
Add --harden-ssh to enforce key-only SSH with no root login. It refuses to
run if no authorized_keys exists anywhere, validates the config with
sshd -t, and reverts if the test fails. Use --ssh-port if you run SSH
somewhere other than 22.
Preview everything first - this changes system state:
sudo orderbook-fe/install.sh --domain example.com --harden --dry-runDeliberately not done: no Content-Security-Policy is set. A wallet dApp loads scripts and opens sockets to extensions, RPC endpoints and indexers; a CSP written without enumerating those origins would break the app. Add one in report-only mode once you know them.
TLS behind Cloudflare#
Use --cloudflare instead of certbot when Cloudflare proxies the hostname
(orange cloud). Create an Origin Certificate in the dashboard under
SSL/TLS → Origin Server → Create Certificate, save the two files, then:
sudo install -d -m 0700 /etc/ssl/cloudflare
sudo install -m 0600 origin.pem /etc/ssl/cloudflare/origin.pem
sudo install -m 0600 origin.key /etc/ssl/cloudflare/origin.key
sudo orderbook-fe/install.sh \
--domain testnet.orderbook.polkadex.ee \
--cloudflare --harden--cloudflare does three things beyond writing a 443 vhost:
- Restores the real client IP. Without it every request appears to come
from a Cloudflare edge address, so
limit_req- which keys on$binary_remote_addr- collapses all users into a handful of buckets and throttles legitimate traffic while an attacker on another edge is unaffected. Logs and fail2ban are equally blind. The installer fetches Cloudflare's published ranges, writesset_real_ip_fromfor each, and setsreal_ip_header CF-Connecting-IP. - Restricts 80/443 to Cloudflare's ranges (with
--harden). Origin IPs are easy to find in DNS history; without this, anyone can send a Host header straight to the origin and skip Cloudflare's WAF and rate limiting entirely. - Caches the range list at
/etc/orderbook-fe/cloudflare-ipsso nginx and the firewall agree. Cloudflare changes these occasionally - re-run the installer to refresh.
Set the Cloudflare SSL mode to Full (strict). An Origin CA certificate is trusted only by Cloudflare, so any other mode either fails or silently downgrades the edge-to-origin hop.
Wildcards match exactly one label - at both ends. This bites twice:
- At the edge, Cloudflare's free Universal SSL covers only
example.comand*.example.com. A third-level name liketestnet.orderbook.example.comhas no edge certificate, and the TLS handshake fails outright. Fix: use a second-level hostname, or buy Advanced Certificate Manager (with Total TLS to auto-issue for every proxied hostname). - At the origin, a
*.example.comcert likewise fails to cover a third-level name. Cloudflare then returns 526 Invalid SSL certificate, which names neither the certificate nor the hostname.
The installer now refuses to proceed unless the origin certificate actually
covers --domain, and prints what it covers versus what is needed. It also
verifies the certificate and key are a matching pair (they must be copied from
the same "Create Certificate" screen - Cloudflare shows the private key only
once) and that the certificate has not expired. All three checks run before
nginx is reconfigured, so a bad certificate cannot take the site down.
Add --cf-origin-pull for Authenticated Origin Pulls: nginx then requires a
client certificate that only Cloudflare holds, so a direct connection to the
origin is refused even from an allowed IP range. Strongest option; verify the
site still loads immediately after enabling it.
Option B - Docker (default)#
The repo ships a multi-stage Dockerfile. The build context is the repo
root. Note that the image is a build sandbox, not the runtime:
deploy.sh extracts the standalone output from it and install.sh runs that
under systemd on the host.
scripts/build-release.sh # -> orderbook-fe:<version>-<sha> + :latest
sudo scripts/deploy.sh # extract, install, restart, health-checkIMAGE_REPO and IMAGE_TAG are read from the environment and default to
orderbook-fe:latest. Point IMAGE_REPO at a registry path to push:
scripts/build-release.sh --repo <registry>/orderbook-fe --pushTo smoke-test the image directly without deploying it:
docker run --rm -p 3000:3000 --env-file apps/hestia/.env orderbook-fe:latestThe final image is ~94 MB and contains no build-time secrets - the ARG/ENV
block lives in the builder stage and runner is a separate FROM that only
copies artifacts. Confirm with:
docker inspect orderbook-fe:latest --format '{{json .Config.Env}}'
# only PATH, NODE_VERSION, YARN_VERSION, NODE_ENV, PORT, HOSTNAMEBuildKit emits SecretsUsedInArgOrEnv warnings for DEFAULT_TRANSFER_TOKEN
(a ticker symbol - false positive), GOOGLE_API_KEY and READ_ONLY_TOKEN
(read by client code, so public by design either way), and SENTRY_AUTH (a
genuine build-time secret, but never present in the final image).
Build needs ~4 GB RAM - NODE_OPTIONS=--max_old_space_size=4096 is set
because the @polkadot/api + chart graph exceeds 2 GB. On a small VPS the
build is OOM-killed with a bare exit code 137. Add swap, or build elsewhere
and ship the image.
Build times#
COPY . . invalidates the build layer on any source change, so the build step
always re-runs. What it must not do is re-run from scratch, which is what a
10-minute rebuild after a one-line edit means.
The Dockerfile keeps three BuildKit cache mounts across builds on the same
host: Next's incremental compiler cache (.next/cache), turbo's task cache
(.turbo), and the yarn download cache. None of them end up in the image.
Expect the first build after a change to these to be full-length (cold cache),
and subsequent ones to be substantially shorter.
Other levers:
sudo scripts/deploy.sh --no-buildreuses the existing local image. Use it when re-running the installer after a config change with no code change.--build-arg TURBO_CONCURRENCY=2parallelises turbo. Default is 1 because the Next build alone peaks near 4 GB and a second concurrent task will OOM a small VPS. Checkfree -hbefore raising it.- Docker prunes cache mounts under disk pressure. If a build is unexpectedly
slow again,
docker system dfwill show whether the cache was evicted.
Option C - Manual (bare Node 22 + systemd)#
yarn install --frozen-lockfile
yarn build # turbo builds @orderbook/{core,chart,format} then hestia--frozen-lockfile requires yarn.lock to be committed - it is, and it must
stay that way. It was .gitignored until 2026-07-26, which made clean-clone
and Docker builds impossible and let every machine resolve dependencies
independently.
The standalone output is not self-contained on disk until you place the two directories Next deliberately excludes:
cp -r apps/hestia/public apps/hestia/.next/standalone/apps/hestia/
cp -r apps/hestia/.next/static apps/hestia/.next/standalone/apps/hestia/.next/Then apps/hestia/.next/standalone/ is the entire deployable artifact - copy
it to the server and run node apps/hestia/server.js.
/etc/systemd/system/ofe.service:
[Unit]
Description=Polkadex Orderbook Frontend
After=network.target
[Service]
Type=simple
User=ofe
WorkingDirectory=/srv/ofe
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOSTNAME=0.0.0.0
ExecStart=/usr/bin/node apps/hestia/server.js
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now ofeReverse proxy (Options B and C)#
Option A's installer writes this for you with --with-nginx. For the other
options, terminate TLS at nginx/Caddy and proxy to 3000. Caddy, which handles certs
automatically:
orderbook.example.com {
reverse_proxy localhost:3000
}
nginx equivalent - note the WebSocket upgrade headers, which this app needs for the chain connection and orderbook subscriptions:
server {
listen 443 ssl http2;
server_name orderbook.example.com;
# ssl_certificate ... (certbot)
location / {
proxy_pass http://127.0.0.1:3000;
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-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Immutable, content-hashed assets
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}Smoke-testing a build before exposing it#
Don't publish the port to test. On the build/target host, bind to loopback:
docker run --rm -p 127.0.0.1:3000:3000 --env-file apps/hestia/.env orderbook-fe:latestand tunnel from your workstation:
ssh -N -L 3000:127.0.0.1:3000 user@host # then open http://localhost:3000Two reasons this is not just about firewalls:
localhostis a secure context;http://<ip>:3000is not. On an insecure origin the browser withholdscrypto.subtle, service-worker registration and clipboard APIs, so wallet connect and the PWA fail for reasons unrelated to the build.- Docker bypasses ufw. It writes its own iptables rules, so
-p 3000:3000is reachable from the internet even whenufw statusreports the port closed. Always bind published ports to127.0.0.1unless a port is genuinely meant to be public.
Caching: what must and must not be cached#
There are three caching layers between a build and a user, and they fail in the same way: someone sees yesterday's app and reports a bug that no longer exists.
1. Content-hashed assets - cache forever. Everything under
/_next/static/* has a content hash in its filename, so it is safe to cache
indefinitely. The nginx vhost already sets
Cache-Control: public, max-age=31536000, immutable for that path.
2. The HTML document - never cache. The document names the chunk hashes for
the build that produced it. Cache it, and after the next deploy it keeps asking
for chunks that no longer exist. Next already sends no-store for it; the risk
is a Cloudflare rule that overrides that.
Add two Cache Rules in Cloudflare. Both must be scoped to the hostname:
Cache Rules apply to the whole zone, so a rule matching only on URI path would
also hit polkadex.ee, explorer.polkadex.ee, docs. and every other
subdomain in the account.
Rule 1 - hashed assets, cache hard
(http.host eq "testnet.polkadex.ee" and starts_with(http.request.uri.path, "/_next/static/"))
- Cache eligibility: Eligible for cache
- Edge TTL: 1 year (or "Respect origin" - nginx already sends
immutable) - Browser TTL: Respect origin
Rule 2 - everything else on that host, never cache
(http.host eq "testnet.polkadex.ee" and not starts_with(http.request.uri.path, "/_next/static/"))
- Cache eligibility: Bypass cache
The two expressions are deliberately mutually exclusive, so they produce the same result regardless of the order they sit in. Rules that overlap depend on precedence, which is easy to get wrong and hard to notice.
Rule 2 matters more than it looks. Cloudflare caches common static extensions
by default, which includes /sw.js - and a stale service worker is the worst
version of this problem, because it then serves stale everything else from the
browser's own cache long after the CDN is correct.
Bypassing the rest costs nothing here: the app is client-rendered with no
cacheable server responses. The only losses are /manifest.json and the PWA
icons, which are a handful of small requests.
For a second environment, duplicate both rules with the new hostname, or widen
the match once the pattern is proven:
http.host in {"testnet.polkadex.ee" "staging.polkadex.ee"}.
Purge after every deploy until those rules are in place. On a multi-tenant
zone use a targeted purge rather than Purge Everything, which would also evict
the marketing site and explorer: Caching → Configuration → Custom Purge →
Hostname → testnet.polkadex.ee.
3. The service worker. @ducanh2912/next-pwa v10 defaults to
skipWaiting: true, clientsClaim: true and cleanupOutdatedCaches: true, so
a new build takes over on the next reload rather than waiting for every tab to
close. No configuration needed - but if the PWA plugin is ever swapped or
downgraded (next-pwa v5 defaults differ), check those three before shipping.
If a user reports something stale, the order to check is: hard reload (Shift + reload, which bypasses the SW), then Cloudflare purge, then the deployed build itself. Two of this project's longest debugging sessions were caching, not code.
Post-deploy checks#
curl -I https://your-domain/→ 200, and/redirects to/trading/<LANDING_PAGE>.- Open the site with devtools: no "Project ID is not defined" boot error
(that's
NEXT_PUBLIC_PROJECT_IDmissing), no anonymous{}WalletConnect errors. - Chart renders - if not, read the console line
[chart] getCandles failed…. Candles come from the REST datafeed gateway, not the GraphQL backend, so checkNEXT_PUBLIC_SERVER_BASE_URLandNEXT_PUBLIC_GATEWAY_SECRET. The error prints the full request URL and the gateway's response body: a 404 usually means an unknown symbol (it resolves tickers, not asset ids) or an unsupported resolution (only1,5,60,1Dare served). Seepackages/chart/README.md. - Faucet nav enabled and
/faucetreachable (needsNEXT_PUBLIC_ENABLE_FAUCETplusNEXT_PUBLIC_FAUCET_URL/_API_KEY). - Bridge page connects a wallet and lists destination accounts.
Updating#
Tarball install - build a new release and re-run the installer; it backs up the old tree and preserves your env file:
scripts/build-release.sh --tarball
scp dist/orderbook-fe-*.tar.gz user@host:/tmp/
ssh user@host 'cd /tmp && tar xzf orderbook-fe-*.tar.gz && sudo orderbook-fe/install.sh'Docker:
git pull
sudo scripts/deploy.shNotes#
- Sentry:
SENTRY_AUTHis only needed to upload source maps at build time. Without it the build still succeeds, sourcemaps just aren't uploaded. - PWA/service worker: enabled in production. After deploying a new version users may need one reload to pick it up; the SW is generated per build.
- Scaling: the app is stateless - run N containers behind a load balancer. No sticky sessions needed (chain/GraphQL websockets are made from the browser, not the server).