How to deploy BEAM Terminal end to end: the indexer + API backend, the static frontend, and the external BEAM explorer-node they read from. Everything runs on a single Linux host, behind a CDN/reverse proxy that terminates TLS.
Hostnames, IP addresses and on-disk paths below are examples — substitute your own. The specifics of any particular production deployment are intentionally kept out of this doc.
explorer-node— a BEAM full node + HTTP explorer API (:8888). External dependency: pin a known-good binary, never build from source.- Postgres + TimescaleDB, the indexer, and the api — run together under docker-compose.
- frontend — a static bundle (
frontend/html/) served straight from disk. - reverse proxy (nginx) behind a CDN (e.g. Cloudflare) that handles public TLS.
/srv/beamterminal/ # this repo, cloned in place (example path)
├── backend/
│ ├── dist/ # tsc output
│ ├── node_modules/
│ ├── migrations/
│ └── .env
├── frontend/
│ ├── html/ # webpack output, served by nginx
│ └── node_modules/
├── docs/
├── nginx.conf # symlinked into /etc/nginx/sites-enabled/
└── docker-compose.yml # postgres + indexer + api
/opt/beam/ # external dependency, managed separately
├── explorer-node # binary, from a BeamMW CI run / release
├── parser.wasm # monolithic explorer parser
└── explorer-data/ # chain DB the explorer-node maintains itself
The explorer-node lives outside the repo because its lifecycle (binary updates, chain DB) is independent of our code.
explorer-node ships in the official BEAM downloads alongside beam-node and the wallet binaries. Grab it from a recent BeamMW/beam GitHub Actions run or a tagged release; place it at /opt/beam/explorer-node.
# Ubuntu 22.04, one-time
sudo useradd -r -s /usr/sbin/nologin beam
sudo mkdir -p /opt/beam/explorer-data
sudo chown -R beam:beam /opt/beam
# Drop the Linux mainnet artifact in place
sudo -u beam install -m 755 /tmp/explorer-node /opt/beam/explorer-node
# Stage the monolithic parser.wasm. Source: bvm/Shaders/Explorer/Parser.wasm
# from a BEAM tree *before* commit 2ab98963 ("feat: explorer modules", 2026-05-03) —
# that commit deleted the monolith and replaced it with per-shader modules under
# --contract_rich_parser_folder. Either pull it out of git history of the BEAM tree
# or copy it from whichever CI run / release matches the explorer-node binary.
sudo -u beam cp /path/to/Parser.wasm /opt/beam/parser.wasm/etc/systemd/system/explorer-node.service:
[Unit]
Description=BEAM explorer-node (mainnet)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=beam
WorkingDirectory=/opt/beam/explorer-data
ExecStart=/opt/beam/explorer-node \
--peer eu-node01.mainnet.beam.mw:8100,eu-node02.mainnet.beam.mw:8100,eu-node03.mainnet.beam.mw:8100 \
--port 10000 \
--api_port 8888 \
--contract_rich_parser /opt/beam/parser.wasm
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
--ip_whitelist— the HTTP API on:8888is public by default. The indexer only needshttp://localhost:8888(loopback, no TLS), so if you don't intend to serve the explorer publicly, gate it with--ip_whitelist 127.0.0.1.- No separate
beam-node—explorer-nodeis itself a full node + HTTP API. P2P sync on:10000, ownexplorer-node.dbin the working folder. - First boot timeline — multi-hour initial sync over P2P.
journalctl -u explorer-node -funtil/statusreturns a height equal to the network tip. The indexer can be started in parallel; it sits idle until sync completes, then begins backfilling.
docker-compose.yml:
services:
postgres:
image: timescale/timescaledb:latest-pg16
restart: always
environment:
POSTGRES_DB: beamterminal
POSTGRES_USER: beamterminal
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
volumes:
- postgres-data:/var/lib/postgresql/data
secrets: [postgres_password]
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U beamterminal -d beamterminal"]
interval: 5s
indexer:
build: ./backend
command: node dist/src/indexer.js
restart: always
depends_on: { postgres: { condition: service_healthy } }
network_mode: host # so it can hit 127.0.0.1:8888 (explorer-node)
env_file: ./backend/.env
api:
build: ./backend
command: node dist/src/api.js
restart: always
depends_on: { postgres: { condition: service_healthy } }
ports:
- "127.0.0.1:3000:3000"
env_file: ./backend/.env
volumes:
postgres-data:
secrets:
postgres_password:
file: ./secrets/postgres_password.txtThe same backend/Dockerfile produces a single image used by both the indexer and the api services — they just override the entrypoint. The Dockerfile is a three-stage build (deps → tsc → runtime) running as the node user.
nginx runs on the host, not in compose — it serves the static React bundle straight from disk and proxies /api and /cg to 127.0.0.1:3000.
Put a CDN/reverse proxy in front to terminate TLS; the origin then only needs to listen on :80. The set_real_ip_from block restores the true client IP from the CDN's forwarded header (e.g. CF-Connecting-IP) so the API's per-IP rate limit keys off the real client, not the proxy.
The config is checked into the repo at nginx.conf. Install it via symlink:
sudo ln -sf /srv/beamterminal/nginx.conf /etc/nginx/sites-enabled/beamterminal
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx- DNS: an
Arecord for your hostname → origin IPv4, proxied (orange cloud). - SSL/TLS mode: Full (origin can later add a self-signed cert without reconfiguration). Flexible works too but is weaker.
- Optional: enable "Always Use HTTPS" so the edge redirects bare HTTP — the origin doesn't need a redirect block.
sudo ufw allow 22 # SSH
sudo ufw allow 80/tcp # nginx (CDN → origin)
sudo ufw allow 8888/tcp # explorer-node HTTP API (only if exposed publicly)
sudo ufw allow 10000/tcp # BEAM P2P
sudo ufw enableNo 443 rule — the CDN reaches the origin on :80. If you later add an origin certificate and bump SSL mode to Full (strict), add listen 443 ssl to nginx.conf and open 443/tcp as well. Optionally restrict port 80 to your CDN's published IP ranges.
# /etc/cron.daily/beamterminal-backup
docker compose -f /srv/beamterminal/docker-compose.yml exec -T postgres \
pg_dump -Fc -U beamterminal beamterminal > /srv/beamterminal/backups/$(date +%F).dump
find /srv/beamterminal/backups -mtime +30 -deleteReplicate off-host via rsync or rclone to object storage nightly. The indexer can rebuild from chain history if all backups vanish — backups are a convenience, not a hard requirement (a full re-backfill takes hours, not days).
# 0. Clone the repo
git clone <repo-url> /srv/beamterminal
cd /srv/beamterminal
# 1. explorer-node — running and synced (see above). Sanity check:
curl http://127.0.0.1:8888/status # should return the current head height
# 2. Backend .env — copy from example and point EXPLORER_URL at your explorer
cp backend/.env.example backend/.env
# edit: EXPLORER_URL=http://localhost:8888, NODE_ENV=production,
# DATABASE_URL=postgres://beamterminal:<pw>@postgres:5432/beamterminal
# 3. Postgres password secret
mkdir -p secrets
openssl rand -hex 32 > secrets/postgres_password.txt
# 4. Bring up DB + services
docker compose up -d postgres
docker compose exec postgres psql -U beamterminal -d beamterminal \
-c "CREATE EXTENSION IF NOT EXISTS timescaledb;"
docker compose run --rm api node dist/scripts/migrate.js # applies all migrations
docker compose up -d indexer api
# 5. Frontend bundle (build-dapp.sh runs build:prod, then bundles + copies
# beamexplorer.dapp into html/ so the nav's "Download DApp" button works).
sudo apt-get install -y zip # build-dapp.sh zips the .dapp
(cd frontend && bash scripts/build-dapp.sh) # writes frontend/html/ + html/beamexplorer.dapp
sudo mkdir -p /var/www/beamterminal
sudo rsync -av --delete frontend/html/ /var/www/beamterminal/
sudo chown -R www-data:www-data /var/www/beamterminal
# 6. nginx
sudo ln -sf /srv/beamterminal/nginx.conf /etc/nginx/sites-enabled/beamterminal
sudo nginx -t && sudo systemctl reload nginx
# 7. Watch the backfill
docker compose logs -f indexer
# Should report "backfill page" lines with eta_seconds until reaching the head
# 8. Point DNS at the origin (proxied). Once /api/health.lag_seconds < 60, it's ready:
curl https://<your-domain>/api/healthcd /srv/beamterminal
git pull
docker compose build api indexer
docker compose up -d api indexer
# Frontend: build-dapp.sh runs build:prod and also bundles the .dapp into html/
# so the "Download DApp" button serves it from the web root (/beamexplorer.dapp).
(cd frontend && bash scripts/build-dapp.sh)
# Publish to the nginx web root (nginx serves /var/www/beamterminal, NOT frontend/html).
# --delete drops files removed from the build; chown so nginx (www-data) can read.
rsync -av --delete frontend/html/ /var/www/beamterminal/
chown -R www-data:www-data /var/www/beamterminal
# Reclaim the layer cache the rebuild just produced — the next deploy's sources
# differ, so it is never reused, and it accumulates deploy over deploy.
docker builder prune -af
# CDNs cache static assets (e.g. /index.js) — purge after publish so users don't
# keep seeing the stale bundle until the TTL expires.Zero-downtime is overkill at this scale — a few seconds of API blip on deploy is fine. Migration files are idempotent (the schema_migrations table tracks what's applied), so re-running migrate.js after git pull is safe.
We don't run a separate IPFS daemon — the wallet-api container already shipping for asset-swaps and the DApp Store projection is built with BEAM_IPFS_SUPPORT and embeds asio-ipfs. Enabling it is purely a matter of CLI flags in backend/docker/wallet-api-entrypoint.sh:
--enable_ipfs--ipfs_swarm_key "$(cat /opt/beam/ipfs/swarm.key)"— the public BEAM mainnet PSK, baked into the image frombackend/ipfs/swarm.key.--ipfs_bootstrapfor eacheu-node0X.mainnet.beam.mw:38041/p2p/…address.
/api/dapp/:cid (Fastify, see backend/src/api/routes/dapp_download.ts) proxies ipfs_get over JSON-RPC and returns the bytes with a Content-Disposition: attachment; filename=… header. The frontend Download button is a plain <a download href="/api/dapp/<cid>?filename=…">, so the browser handles streaming / progress / right-click "Save as".
Smoke test once wallet-api is up:
curl -fsSL -o /tmp/bridge.dapp \
"https://<your-domain>/api/dapp/QmPWrArdausWmzB44nygzK8nxjuuQGk9MBNuWyMCHacRtn?filename=BridgeApp.dapp"
file /tmp/bridge.dapp # expect "Zip archive data"Why this path and not a standalone Kubo daemon: we tried — Kubo's bitswap can dial BEAM's bootstrap peers (PSK works) but those peers don't store dapp blocks themselves, and the private-swarm DHT has only those few peers, so content lookup dead-ends. asio-ipfs succeeds because it has BEAM-specific peer-discovery logic Kubo doesn't replicate.
The .dapp bundle is built from the same frontend source:
yarn --cwd frontend build:dapp
# produces beamterminal-<commit-count>.dappThe build script versions automatically off git rev-list --count HEAD. Upload the artifact to the BEAM Dapp Store via the team's existing submission flow. See frontend.md §.dapp bundle for the manifest shape.
journalctl -u explorer-nodefor the chain layer.docker compose logs -f api indexerfor our services.https://<your-domain>/api/healthvia any uptime probe (Uptime Kuma, BetterStack, etc.).- Alerting suggestions:
/api/health.lag_seconds > 300for > 5 min → page (indexer fell behind).oracle_snapshots.tsolder than 4 h → page (oracle stalled = USD numbers go dark).- Postgres container down → page.
Each docker compose build leaves a layer cache the next deploy cannot reuse, so
it accumulates across deploys. Reclaim it as the last step of every deploy that
rebuilt an image:
docker builder prune -af