Skip to content

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:

  1. 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.
  2. Images: jua-api (the Dockerfile's api target, run from source, nothing baked in), jua-web and jua-admin (one Dockerfile, ARG APP; the NEXT_PUBLIC_* values are build arguments, public only). The web image builds the Telegram Mini App into the site's /tg/.
  3. Migrations: bun run db:migrate with the DIRECT_URL secret, 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.
  4. The docs site: bun run docs:build builds this site (dead internal links fail it).
  5. 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.web and the docs build are staged as *.next in /opt/jua.
  6. Swap: the staged files replace the running ones (kept as *.prev), docker compose up -d swaps the containers, docs.next becomes docs.
  7. 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:
    bash
    cd /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 ​

KindNameValue
secretSERVER_HOST, SERVER_KNOWN_HOSTSThe VM's IP; ssh-keyscan -t ed25519,ecdsa,rsa <ip> output, fingerprints checked against the server's own
secretSERVER_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
secretDATABASE_URL, DIRECT_URLSupabase's transaction pooler (6543) and session pooler (5432) strings
variableSITE_URL, ADMIN_URLhttps://juamarkets.com, https://admin.juamarkets.com: built into the images and checked by the api
variableBIND_ADDRESS127.0.0.1 behind the TLS edge, so the containers listen on loopback only
variableNEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY; secret SUPABASE_SERVICE_ROLE_KEYSupabase Auth: Setting up Supabase
variableNEXT_PUBLIC_MONEY_MODEplay 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) ​

KindNameValue
variableEXCHANGE_ADDRESS, EXCHANGE_CTF, EXCHANGE_CHAIN_ID, EXCHANGE_TOKEN, EXCHANGE_TOKEN_DECIMALS, EXCHANGE_TOKEN_SYMBOL, EXCHANGE_START_BLOCKWhat 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
variableEXCHANGE_RESOLVERThe resolver's address, every condition's oracle
variableEXCHANGE_FEE_VAULTThe JuaVault that is the exchange's fee receiver. Unset: no commissions on wallet trades
secretEXCHANGE_RPC_URL, EXCHANGE_RPC_URL_SECONDARYTwo RPCs from two providers (the URLs carry keys)
secretEXCHANGE_OPERATOR_KEY, or variables EXCHANGE_OPERATOR_SIGNER_URL + EXCHANGE_OPERATOR_ADDRESS and secret EXCHANGE_OPERATOR_SIGNER_AUTHThe operator key, or a remote signer (KMS/HSM) that holds it: preferred with real money
secretEXCHANGE_RESOLVER_KEYOnly while the api records results itself; unset when the resolver is a Safe

Trading from TON wallets (the Mini App) ​

KindNameValue
variableTON_NETWORKmainnet or testnet
variableTON_EXCHANGE_ADDRESS, TON_EXCHANGE_JETTON_MASTER, TON_EXCHANGE_START_LT, TON_EXCHANGE_FEE_VAULTWhat 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
variableTON_TONCENTER_URL, TON_TONAPI_URL; secrets TON_TONCENTER_API_KEY, TON_TONAPI_KEYTwo indexers from two providers, which must agree on every read
secretTON_EXCHANGE_OPERATOR_KEY; variable TON_EXCHANGE_OPERATOR_WALLET (v5r1 or v4)The operator wallet's seed and contract
secretTON_EXCHANGE_RESOLVER_KEY; variable TON_EXCHANGE_RESOLVER_WALLETOnly while the api records results; unset when the resolver is a multisig
variableTON_ALLOW_MAINNETtrue, on purpose, once the testnet rehearsal and the audit are done
secretTON_MARKET_MAKER_KEY; variables TON_MARKET_MAKER_WALLET, _ORDER_USD, _SPREAD_CENTS, _MAX_USD, _MARKETSJua's own market maker on TON (optional)

Telegram, alerts, housekeeping and the rest ​

KindNameValue
secretTELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET; variables TELEGRAM_BOT_USERNAME, TELEGRAM_MINIAPP_SHORT_NAMEThe bot (Telegram). TELEGRAM_MINIAPP_URL and TELEGRAM_WEBHOOK_URL default to the site's /tg/ and /api/webhooks/telegram
secretALERT_WEBHOOK_URL, ALERT_WEBHOOK_SECRETWhere staff alerts go out of band; the secret signs each message
secretHOUSEKEEPING_HEARTBEAT_URLA dead man's switch pinged after every housekeeping pass
secretCRON_SECRET; variable HOUSEKEEPING_TIMERFor an external scheduler: off stops the api's own timer (Housekeeping)
variableHOUSEKEEPING_STALE_MINUTESThe watchdog's threshold (10)
secretANTHROPIC_API_KEY; variables LISTING_TRANSLATION, LISTING_SCOUT, AUTO_RESOLVEListing translations, the AI listing scout and automatic resolution (the AI); off stops each
variableDEMO_MODE, MARKET_MAKER, API_DOCS, TRUSTED_PROXY_HOPS, NODE_VERSIONThe 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
variableDOCS_HOSTdocs.juamarkets.com, for the compose edge's docs site block
variableCRYPTO_PROVIDER, VAULT_*The vault model only (balances from before trading from wallets). Unset: USDT custody off
variableNEXT_PUBLIC_WALLETCONNECT_PROJECT_IDReown (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:build at the repository root (vitepress build), output in apps/docs/.vitepress/dist. Dead internal links fail the build. bun run docs:dev serves it locally on port 3400; bun run docs:preview serves 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 as docs.prev for 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/docs at /srv/docs and serves it when DOCS_HOST names the host. With host nginx, infra/deploy/nginx/jua.conf.example has 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.

  1. SSL/TLS → Overview: Full (strict). Cloudflare then checks nginx's Let's Encrypt certificate.
  2. 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:
    bash
    { 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
    Refresh 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.
  3. 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.
  4. 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-Control themselves, and Cloudflare caches no HTML they mark private.
  5. docs.juamarkets.com needs its own proxied DNS record and the deploy's DOCS_HOST variable; until both exist it answers nothing.

Jua Markets · Africa's prediction market.