Railway / cloud deployment
The Docker compose stack translates cleanly to any container platform. Notes for Railway (what we tested against) and general guidance.
Railway
Split into two services:
ib-gateway— use theghcr.io/gnzsnz/clientportalimage, pin by digest. Private networking only; don’t expose port 5000 publicly.bezant-server— build from this repo’sDockerfile. SetIBKR_GATEWAY_URL=https://ib-gateway.railway.internal:5000/v1/apiso it reaches the Gateway over Railway’s private network.
You will still need to log in to the Gateway once via a VNC / RDP tunnel to complete the initial IBKR 2FA. The Gateway keeps the session alive after that; Bezant keeps it tickled.
Combined-image deploy (single Railway service)
The ghcr.io/isaacrowntree/bezant-combined image runs CPGateway and
bezant-server together behind one entrypoint, which is what most
single-user deployments want. Two mandatory env vars when you run
this image behind a public hostname that differs from localhost:5000:
| Env var | Required | What it does |
|---|---|---|
IBKR_GATEWAY_URL | yes | Always https://127.0.0.1:5000/v1/api for the combined image — bezant-server talks to the in-container Gateway. |
PORTAL_BASE_URL | yes when public hostname ≠ localhost | The full origin (https://your-host.up.railway.app) the browser will see. The entrypoint substitutes this into CPGateway’s conf.yaml at boot. |
Why it matters. CPGateway’s CPAPI handlers refuse post-login requests with HTTP 401 when the browser-supplied
Origin/Refererdon’t match theportalBaseURLit was configured with. The default empty value works forlocalhost-to-localhost, but breaks the moment a reverse proxy puts you on a different hostname (Railway, fly.io, ngrok, …). On Railway the entrypoint will fall back tohttps://${RAILWAY_PUBLIC_DOMAIN}automatically; on other platforms setPORTAL_BASE_URLexplicitly.
Why pass-through
bezant-server’s handlers forward the Gateway’s JSON body verbatim —
they don’t decode into typed Rust structs and re-encode as JSON. Three
reasons:
- No schema drift on the hot path. If IBKR adds a new field to
portfolio/summary, your consumers see it immediately with zero code changes in bezant. - Smaller attack surface. Pass-through means the sidecar can’t accidentally strip fields or round-trip floats incorrectly.
- Faster. No double decode; just stream bytes.
The typed layer is bezant-api, which we consciously keep separate.
Rust consumers that want typed access link the crate directly; anyone
going over HTTP just needs JSON.
Secrets
- Gateway login — ideally stays in the Gateway image’s config, bound to the account owner’s 2FA device. Don’t paste IBKR passwords into container env vars.
RELEASE_PLZ_TOKEN/CARGO_REGISTRY_TOKEN— repo secrets for the GitHub Actions release workflow (optional until we publish to crates.io).
Health checks
The Gateway exposes /v1/api/iserver/auth/status. bezant-server exposes
/health. Hook your platform’s health check to /health — it returns
{"authenticated": true, ...} with HTTP 200 only when IBKR is happy.