Theme
Deploying
Production runs on one VM with docker compose: the api, the customer site and the staff portal, plus the static docs site. Deploys run only from GitHub Actions, on every push to the prod branch. Nothing is cloned, built or edited by hand on the server, and all configuration comes from the repository's Actions secrets (sensitive) and variables (public), under Settings → Secrets and variables → Actions.
How a deploy runs
.github/workflows/deploy.yml, on a push to prod:
- CI (
ci.yml) runs alongside: lint, typecheck and the schema check must pass, or the deploy stops before touching anything. The unit tests run too and report on their own job. - Images:
jua-api(the Dockerfile'sapitarget, run from source, nothing baked in),jua-webandjua-admin(one Dockerfile,ARG APP; theNEXT_PUBLIC_*values are build arguments, public only). The web image builds the Telegram Mini App into the site's/tg/. - Migrations:
bun run db:migratewith theDIRECT_URLsecret, from the runner. Containers never migrate. A failed migration stops the deploy. Migrations are forward-only and never rolled back, so each must keep working with the previous release's code. - The docs site:
bun run docs:buildbuilds this site (dead internal links fail it). - Ship: the images stream to the server over SSH (
docker save | gzip | ssh 'docker load', no registry); the compose file,.env.server,.env.api,.env.weband the docs build are staged as*.nextin/opt/jua. - Swap: the staged files replace the running ones (kept as
*.prev),docker compose up -dswaps the containers,docs.nextbecomesdocs. - Health: api, web and admin must report healthy within 3 minutes each, or the logs are printed and the server goes back to the last healthy release (
/opt/jua/.deployed) with its settings and its docs.
A push that changes only the repository's docs/ folder or Markdown files outside apps/docs does not deploy; a change under apps/docs/ does.
Everyday
- Deploy: merge or push into
prod. Watch it under Actions → Deploy. - Change a setting (a variable or secret): edit it, then Actions → Deploy → Run workflow with Settings only ticked. It rewrites the env files and restarts the running release in about a minute, with no CI, build, migrations or docs. Not for
NEXT_PUBLIC_*values, which are built into the web and admin images: untick Settings only for a full deploy. - Deploy again without a commit: Run workflow with Settings only unticked.
- Roll back by hand: revert the commit on
prod, or on the server:bashcd /opt/jua && cat .deployed # the last healthy release docker images jua-api # the releases still on the server (this one and the previous) printf 'IMAGE_TAG=%s\n' <tag> > .env.release docker compose --env-file .env.server --env-file .env.release up -d --no-build --pull never - Look at it:bash
cd /opt/jua && docker compose --env-file .env.server --env-file .env.release ps docker compose --env-file .env.server --env-file .env.release logs --tail 100 api - One-off database jobs (
db:status,db:staff,db:seed): from a checkout on your own machine against the same Supabase project, never from the server.
Secrets and variables
The deploy writes one env file per reader, so each container holds only what it reads: .env.server (public values that fill the compose file), .env.api (the API's settings and secrets) and .env.web (the site's database and switches). The staff portal gets none. A value may not contain a single quote or a newline.
Always
| Kind | Name | Value |
|---|---|---|
| secret | SERVER_HOST, SERVER_KNOWN_HOSTS | The VM's IP; ssh-keyscan -t ed25519,ecdsa,rsa <ip> output, fingerprints checked against the server's own |
| secret | SERVER_SSH_KEY (or SERVER_PASSWORD) | A deploy key of its own, its .pub in the login user's authorized_keys; SERVER_USER if not root |
| secret | DATABASE_URL, DIRECT_URL | Supabase's transaction pooler (6543) and session pooler (5432) strings |
| variable | SITE_URL, ADMIN_URL | https://juamarkets.com, https://admin.juamarkets.com: built into the images and checked by the api |
| variable | BIND_ADDRESS | 127.0.0.1 behind the TLS edge, so the containers listen on loopback only |
| variable | NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY; secret SUPABASE_SERVICE_ROLE_KEY | Supabase Auth: Setting up Supabase |
| variable | NEXT_PUBLIC_MONEY_MODE | play or real. Recorded in the database at the api's first start; a play-money database can never turn real |
Trading from wallets (BNB Smart Chain)
| Kind | Name | Value |
|---|---|---|
| variable | EXCHANGE_ADDRESS, EXCHANGE_CTF, EXCHANGE_CHAIN_ID, EXCHANGE_TOKEN, EXCHANGE_TOKEN_DECIMALS, EXCHANGE_TOKEN_SYMBOL, EXCHANGE_START_BLOCK | What deploy:exchange prints: the exchange, its Conditional Tokens, the chain (56; 97 testnet), the coin (USDT, 18 decimals on BNB Smart Chain), the deployment block |
| variable | EXCHANGE_RESOLVER | The resolver's address, every condition's oracle |
| variable | EXCHANGE_FEE_VAULT | The JuaVault that is the exchange's fee receiver. Unset: no commissions on wallet trades |
| secret | EXCHANGE_RPC_URL, EXCHANGE_RPC_URL_SECONDARY | Two RPCs from two providers (the URLs carry keys) |
| secret | EXCHANGE_OPERATOR_KEY, or variables EXCHANGE_OPERATOR_SIGNER_URL + EXCHANGE_OPERATOR_ADDRESS and secret EXCHANGE_OPERATOR_SIGNER_AUTH | The operator key, or a remote signer (KMS/HSM) that holds it: preferred with real money |
| secret | EXCHANGE_RESOLVER_KEY | Only while the api records results itself; unset when the resolver is a Safe |
Trading from TON wallets (the Mini App)
| Kind | Name | Value |
|---|---|---|
| variable | TON_NETWORK | mainnet or testnet |
| variable | TON_EXCHANGE_ADDRESS, TON_EXCHANGE_JETTON_MASTER, TON_EXCHANGE_START_LT, TON_EXCHANGE_FEE_VAULT | What deploy:ton prints: JuaTonExchange, the USDT master (Tether's on mainnet; the test jetton on testnet), the logical time to read from (the exchange's deployment), the JuaTonVault |
| variable | TON_TONCENTER_URL, TON_TONAPI_URL; secrets TON_TONCENTER_API_KEY, TON_TONAPI_KEY | Two indexers from two providers, which must agree on every read |
| secret | TON_EXCHANGE_OPERATOR_KEY; variable TON_EXCHANGE_OPERATOR_WALLET (v5r1 or v4) | The operator wallet's seed and contract |
| secret | TON_EXCHANGE_RESOLVER_KEY; variable TON_EXCHANGE_RESOLVER_WALLET | Only while the api records results; unset when the resolver is a multisig |
| variable | TON_ALLOW_MAINNET | true, on purpose, once the testnet rehearsal and the audit are done |
| secret | TON_MARKET_MAKER_KEY; variables TON_MARKET_MAKER_WALLET, _ORDER_USD, _SPREAD_CENTS, _MAX_USD, _MARKETS | Jua's own market maker on TON (optional) |
Telegram, alerts, housekeeping and the rest
| Kind | Name | Value |
|---|---|---|
| secret | TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET; variables TELEGRAM_BOT_USERNAME, TELEGRAM_MINIAPP_SHORT_NAME | The bot (Telegram). TELEGRAM_MINIAPP_URL and TELEGRAM_WEBHOOK_URL default to the site's /tg/ and /api/webhooks/telegram |
| secret | ALERT_WEBHOOK_URL, ALERT_WEBHOOK_SECRET | Where staff alerts go out of band; the secret signs each message |
| secret | HOUSEKEEPING_HEARTBEAT_URL | A dead man's switch pinged after every housekeeping pass |
| secret | CRON_SECRET; variable HOUSEKEEPING_TIMER | For an external scheduler: off stops the api's own timer (Housekeeping) |
| variable | HOUSEKEEPING_STALE_MINUTES | The watchdog's threshold (10) |
| secret | ANTHROPIC_API_KEY; variables LISTING_TRANSLATION, LISTING_SCOUT, AUTO_RESOLVE | Listing translations, the AI listing scout and automatic resolution (the AI); off stops each |
| variable | DEMO_MODE, MARKET_MAKER, API_DOCS, TRUSTED_PROXY_HOPS, NODE_VERSION | The demo sign-in (play money only), the house market maker (play money only), the API reference, proxies in front of the api, the base image pinned by digest |
| variable | DOCS_HOST | docs.juamarkets.com, for the compose edge's docs site block |
| variable | CRYPTO_PROVIDER, VAULT_* | The vault model only (balances from before trading from wallets). Unset: USDT custody off |
| variable | NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID | Reown (WalletConnect) project, so phone wallets connect by QR code. Built in: a full deploy |
The full reference, with where each value comes from, is docs/deployment.md in the repository; the API's apps/api/.env.example documents every variable the api reads.
The docs site
This site is apps/docs, a VitePress static site. Its place in the pipeline:
- Build:
bun run docs:buildat the repository root (vitepress build), output inapps/docs/.vitepress/dist. Dead internal links fail the build.bun run docs:devserves it locally on port 3400;bun run docs:previewserves a build on 3401. - Ship: the deploy workflow builds it after the migrations and ships the output to
/opt/jua/docs.next; the restart step swaps it to/opt/jua/docs, keeping the previous build asdocs.prevfor a rollback. A settings-only run ships no docs and keeps what is there. - Serve: a static-file site block for
docs.juamarkets.com. With the compose edge (--profile edge,infra/deploy/Caddyfile), the edge mounts/opt/jua/docsat/srv/docsand serves it whenDOCS_HOSTnames the host. With host nginx,infra/deploy/nginx/jua.conf.examplehas a server block for<DOCS_DOMAIN>serving/opt/jua/docs; copy it beside the site's and portal's blocks and add the name to the certificate (certbot certonly --nginx -d juamarkets.com -d admin.juamarkets.com -d docs.juamarkets.com).
DNS
The DNS record for docs.juamarkets.com must point at the server: an A record (and AAAA, if the server has IPv6) to the VM's address, like the site's and the portal's, with no CDN proxy in front. The pipeline does not touch DNS.
Staff accounts
There is no shared staff password. Each staff member gets an account with bun run db:staff <email> <admin|resolver>, run from a checkout on your own machine against the production database (DIRECT_URL in packages/db/.env.local); it prints the password once. See Keys and roles.
Domains and TLS
juamarkets.com and admin.juamarkets.com run behind nginx on the host with Let's Encrypt certificates (docs/deployment.md, "When Jua gets a domain"): an A record per name, a port-80 block for certbot, then infra/deploy/nginx/jua.conf.example with the names filled in, BIND_ADDRESS=127.0.0.1, and a full deploy with SITE_URL and ADMIN_URL. Supabase Auth needs the site's URL among its redirect URLs and the Web3 Wallet providers on. The compose edge (Caddy) is the alternative when nothing else fronts the machine.
Behind Cloudflare
juamarkets.com is served through Cloudflare (proxied DNS records). Cloudflare works in front of nginx once these are done; the live checks (bun run e2e:live) show what is still missing.
- SSL/TLS → Overview: Full (strict). Cloudflare then checks nginx's Let's Encrypt certificate.
- The visitor's address, not Cloudflare's. Without this, nginx's per-address limits and the API's rate limits count a Cloudflare server instead of the visitor: customers share one allowance, and one client spreads over many. On the server, write Cloudflare's published ranges into an include, uncomment the two lines in the nginx config (
infra/deploy/nginx/jua.conf.example), and reload:bashRefresh the file when Cloudflare announces new ranges (Recurring tasks). The live checks' "the SSE stream caps how many one client opens" turns from a warning to a pass once it works.{ curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6; } \ | sed '/^$/d; s/.*/set_real_ip_from &;/' | sudo tee /etc/nginx/cloudflare-real-ip.conf sudo nginx -t && sudo systemctl reload nginx - Only Cloudflare reaches the server on 443. Otherwise anyone can skip Cloudflare and name any visitor address in
CF-Connecting-IP. Allow 443 from the same ranges in the firewall (or turn on Cloudflare's Authenticated Origin Pulls and require the client certificate in nginx); keep 80 open for certbot's renewal, or switch it to the DNS challenge. - Features that rewrite pages, off. The site's Content-Security-Policy refuses scripts it did not send, so Cloudflare's injected ones fail in every visitor's console and do nothing: Web Analytics' automatic setup (Analytics & Logs → Web Analytics → the site → disable automatic setup, or use the analytics without the injected beacon), Rocket Loader and Email Address Obfuscation (Speed / Scrape Shield). Caching stays as it is: the apps send
Cache-Controlthemselves, and Cloudflare caches no HTML they mark private. - docs.juamarkets.com needs its own proxied DNS record and the deploy's
DOCS_HOSTvariable; until both exist it answers nothing.