Architecture at a Glance¶
FTL runs three Go binaries, all deployed as Azure Container Apps. They share two data stores: PostgreSQL Flex (durable state) and Azure Cache for Redis (hot state, atomic execution, pub/sub backplane).
The three services¶
| Service | Port | Role |
|---|---|---|
api-server |
8080 | HTTP REST — trades, portfolio, auth (Google OAuth), Sportmonks polling |
ws-server |
8081 | WebSocket — accepts client subscriptions, fans out price updates |
flusher |
— | No ingress. Background worker that drains Redis dirty keys into PostgreSQL every 100–200 ms |
Decision: flusher has no HTTP ingress. It runs a tight loop reading a Redis dirty set and writing batches to Postgres. This keeps api-server latency low — the trade API returns immediately after writing to Redis; durability follows asynchronously.
How the services connect¶
Trade hot-path (user opens a CFD position):
api-serverreceivesPOST /api/positions/open.- It calls
EVALSHAon theposition_open.luaLua script via Redis. The Lua script readsinstrument:{id}andwallet:{userId}, computes the AMM price, validates slippage, locks margin, updates both keys, updatesleaderboard:overall, and publishes onprice:{instrumentId}— all atomically in one Redis call. api-serverpersists the new position row to thecfd_positionstable in PostgreSQL.ws-serverreplicas subscribe to allprice:*channels. EachPUBLISHfrom api-server triggers a fan-out to every connected client watching that instrument.flusherperiodically reads keys in the dirty set and batch-writes wallet and position state to PostgreSQL.
Decision: Redis Lua scripts instead of a message queue — single-threaded Redis serialises concurrent trades on the same instrument with zero lock contention and sub-millisecond latency. A message queue (Service Bus, Kafka) adds ordering delay and cost.
Real-time price broadcast (base price changes from Sportmonks):
api-serverpolls Sportmonks every 2 seconds during live matches.- It normalises the rating into a base price, writes to
price_ticksin PostgreSQL, and setsinstrument:{id}in Redis. - Redis pub/sub carries the update to all
ws-serverreplicas. - Each replica fans out a
PRICE_UPDATEMessagePack frame (95 bytes on wire) to subscribed clients.
Authentication:
- Client sends a Google ID token to
POST /api/auth/google. api-servervalidates it against cached Google public keys and issues a short-lived access JWT (15 min default) and a 7-day refresh JWT.- All subsequent API and WebSocket requests carry
Authorization: Bearer <JWT>.
Decision: raw Google OAuth 2.0 instead of Firebase Auth or Azure AD B2C — cost is $0 at any MAU scale.
Component diagram¶
graph LR
subgraph Client
Browser["React SPA\n(Static Web Apps)"]
end
subgraph Edge
CF["Cloudflare\nCDN + WAF"]
end
subgraph ContainerApps["Azure Container Apps"]
API["api-server\n2 replicas · port 8080\nGo + Fiber + pgx"]
WS["ws-server\n3 replicas · port 8081\nGo + gorilla/websocket"]
FL["flusher\n1 replica · no ingress\nGo background worker"]
end
subgraph Data
PG["PostgreSQL Flex\nD2ds_v5 · 256 GiB\nDurable state"]
RD["Redis Standard C1\n1 GB · 40K ops/sec\nHot state + pub/sub"]
end
subgraph External
SM["Sportmonks API\nLive player ratings"]
GO["Google OAuth 2.0\nToken validation"]
end
Browser -->|HTTPS REST| CF
CF -->|api.ftl2026.com| API
CF -->|wss://api.ftl2026.com| WS
API -->|pgx pool| PG
API -->|go-redis| RD
API -->|EVALSHA position_open.lua| RD
API -->|PUBLISH price:*| RD
API -->|poll every 2s| SM
API -->|validate token| GO
WS -->|SUBSCRIBE price:*| RD
WS -->|PRICE_UPDATE frames| Browser
FL -->|read dirty keys| RD
FL -->|batch COPY/INSERT| PG
Data store roles¶
| Store | What it owns | What it does NOT own |
|---|---|---|
| PostgreSQL | Source of truth: users, wallets, cfd_positions, trades, price_ticks, leaderboard_snapshots |
Real-time read path for wallets or leaderboard during trades |
| Redis | Hot state: wallet:{userId}, instrument:{id}, leaderboard:overall, leaderboard:district:{id} |
Persistence — RDB/AOF are disabled; Redis is a write-through cache |
Warning
Redis is not the source of truth. If Redis crashes, the flusher rebuilds hot state from the latest leaderboard_snapshots row in PostgreSQL and replays recent trades. Recovery takes under 60 seconds.
Per-service resource targets¶
| Service | vCPU | GiB RAM | Max connections / replicas |
|---|---|---|---|
| ws-server | 2 | 4 | 25 K WebSocket per replica, 3 replicas |
| api-server | 1 | 2 | pgx pool 20, go-redis pool 20 |
| flusher | 0.5 | 1 | — |
The p95 trade latency target is <5 ms end-to-end. The Redis Lua execution budget is <0.5 ms.