Current Stack Architecture¶
FTL runs on Go microservices hosted on Azure Container Apps, backed by PostgreSQL Flex and Azure Cache for Redis. The stack is sized for 50K concurrent users across a 39-day tournament (June 11 – July 19, 2026).
Language and HTTP Framework¶
Go 1.22 + Fiber v2
Fiber wraps fasthttp and avoids the net/http allocations that add latency at high concurrency. Every service (api-server, ws-server, flusher) is a standalone Go binary.
Decision: Go over Node.js or Python — statically compiled binary, goroutine-per-connection model handles 50K WebSocket connections with predictable memory; no GIL, no runtime startup overhead, no npm supply chain.
Entry points:
- ftl-backend/cmd/api-server/main.go — HTTP API
- ftl-backend/cmd/ws-server/main.go — WebSocket server
- ftl-backend/cmd/flusher/main.go — background flusher
The Fiber app is configured with ProxyHeader: "X-Forwarded-Proto" so HSTS activates correctly behind Container Apps' TLS-terminating proxy.
Database¶
Azure Database for PostgreSQL Flexible Server 16
| Setting | Value |
|---|---|
| SKU | D2ds_v5 (2 vCPU, 8 GB RAM) |
| Storage | 256 GiB Premium SSD |
| Pooler | PgBouncer, transaction mode, port 6432 |
| Extensions | pg_stat_statements, pg_cron, pg_partman, pgcrypto |
| HA | Disabled |
| Backup | LRS, 7-day retention |
Decision: PostgreSQL Flex over Cosmos DB — standard SQL, cheaper for write-heavy workloads, no per-RU pricing, familiar tooling, pg_cron for periodic tasks.
The api-server and flusher connect through pgxpool (Go's idiomatic pgx pool). The api-server pool is capped at 20 connections; PgBouncer multiplexes those to the DB server (default pool size 50, max client connections 2000).
Cache and Trade Execution¶
Azure Cache for Redis — Standard C1 (1 GB)
| Setting | Value |
|---|---|
| Ops/sec capacity | 40K ops/sec |
| Working set | ~180 MB (18% of 1 GB) |
| Persistence | Disabled — Postgres is the source of truth |
maxmemory-policy |
allkeys-lru |
Decision: Redis Lua scripts over Azure Service Bus — single-threaded atomic execution eliminates distributed locks. One Lua script call replaces what would require a Service Bus enqueue + worker dequeue + optimistic retry cycle. Latency drops from ~50ms (Service Bus round trip) to <1ms (in-process Lua).
The four Lua scripts live at:
- ftl-backend/internal/redis/lua/trade_execute.lua — legacy BUY/SELL
- ftl-backend/internal/redis/lua/position_open.lua — CFD open (ADR-0004)
- ftl-backend/internal/redis/lua/position_close.lua — CFD close (ADR-0004/ADR-0005/ADR-0006)
- ftl-backend/internal/redis/lua/ping.lua — health check
Each script runs in Redis's single-threaded interpreter. No two scripts execute concurrently for the same Redis instance, giving atomic read-modify-write without any application-level locking.
Container Hosting¶
Azure Container Apps — Consumption Plan, Central India
| Service | Replicas (peak) | vCPU | GiB | Role |
|---|---|---|---|---|
| ws-server | 3 (min 2) | 2 | 4 | WebSocket connections + fan-out |
| api-server | 2 (min 2) | 1 | 2 | HTTP API, Sportmonks poller |
| flusher | 1 (never scales) | 0.5 | 1 | Redis → Postgres sync |
| Peak total | 6 | 8.5 | 17 |
maxReplicas hard limits: ws-server = 5, api-server = 5, flusher = 1. Without these limits, auto-scale could reach $5,000+/mo.
ws-server requires two non-default ingress settings:
ingress:
transport: auto # NOT http2 — WebSocket uses HTTP/1.1 upgrade
stickySessions:
affinity: sticky # pins a client to the same replica after WS upgrade
Decision: custom Go WebSocket server on Container Apps over Azure Web PubSub — Web PubSub costs ~$1,200/mo for 50K connections. Three Container Apps replicas at $30/mo each total ~$90/mo: roughly 15x cheaper.
ws-server must run in Single revision mode for sticky sessions to work. api-server and flusher use standard HTTP and can run in multi-revision mode for blue/green deploys.
Warning
Never run scale-up.sh or scale-down.sh against a Container App in Multiple revision mode. That configuration caused a ₹1,234/day overrun in staging (May 2026) by accumulating 9 warm revisions. Always verify activeRevisionsMode == "Single" first.
Authentication¶
Raw Google OAuth 2.0
The api-server validates Google ID tokens directly against Google's JWKS endpoint. auth.NewGoogleVerifier fetches keys on startup and refreshes them in the background via googleVerifier.StartRefresh(ctx).
Post-validation, the server issues its own JWT signed with a local RSA key pair (JWTPrivateKey / JWTPublicKey from Key Vault), using RS256. Three token types exist: access, refresh, and a short-lived ws-token (60-second TTL) used for the WebSocket upgrade handshake.
Decision: raw Google OAuth over Auth0 or Firebase Auth — $0 MAU cost at any scale. Firebase Auth charges per MAU beyond 10K users. At 50K tournament users, Firebase would add ~$150/mo; Auth0 would add ~$800/mo.
Token generation: ftl-backend/scripts/gen-dev-keys.sh.
Cost Breakdown¶
Verified via Azure Retail Prices API, April 2026, Central India region.
| Service | Monthly (USD) | Monthly (INR) |
|---|---|---|
| Container Apps (6 replicas, active billing) | $455 | ₹37,993 |
| Bandwidth (3,100 GB) | $357 | ₹29,810 |
| PostgreSQL Flex D2ds_v5 + 256 GiB | $217 | ₹18,120 |
| Sportmonks API | $75 | ₹6,263 |
| Redis Standard C1 | $50 | ₹4,175 |
| Azure Communication Services (email) | $25 | ₹2,088 |
| Static Web Apps Standard | $9 | ₹752 |
| Container Registry Basic | $5 | ₹418 |
| Key Vault Standard | $2 | ₹167 |
| Application Insights (1% sampling) | $0 | ₹0 |
| Cloudflare Free | $0 | ₹0 |
| Total | ~$1,200 | ~₹1,00,200 |
Tournament total (39 days): ~$1,560 / ₹1,30,260.
Deployment Topology¶
graph TB
subgraph Clients
Browser["React SPA\n(Static Web Apps)"]
end
subgraph Edge
CF["Cloudflare Free\nCDN + DDoS + WAF"]
end
subgraph "Azure Container Apps Environment"
WS["ws-server\n3 replicas × 2vCPU/4GiB"]
API["api-server\n2 replicas × 1vCPU/2GiB"]
FL["flusher\n1 replica × 0.5vCPU/1GiB"]
end
subgraph "Data Layer"
PG["PostgreSQL Flex\nD2ds_v5 / 256 GiB"]
RD["Redis Standard C1\n1 GB / Lua + pub/sub"]
end
subgraph "External"
SM["Sportmonks API\n€69/mo"]
GO["Google OAuth 2.0\n$0"]
EM["Azure Communication Services\n~$25/mo"]
end
subgraph "Operations"
KV["Key Vault\n10 secrets"]
CR["Container Registry\nBasic"]
AI["Application Insights\n1% sampling"]
end
Browser -->|HTTPS| CF
CF -->|api.ftl2026.com| API
CF -->|wss://api.ftl2026.com sticky| WS
API -->|pgxpool 20 conns| PG
API -->|go-redis 20 conns| RD
API -->|poll every 2s| SM
API -->|validate token| GO
API -->|queue email| EM
WS -->|go-redis subscribe| RD
FL -->|batch writes| PG
FL -->|read dirty sets| RD
KV -.->|secrets at startup| API
KV -.->|secrets at startup| WS
KV -.->|secrets at startup| FL
CR -.->|image pull| API
CR -.->|image pull| WS
CR -.->|image pull| FL
API -->|telemetry| AI
WS -->|telemetry| AI
Other Services¶
| Service | Role |
|---|---|
| Cloudflare Free | CDN for static assets (98%+ cache hit), DDoS, WAF, TLS termination. Enforces 100s WebSocket idle timeout — clients must send a heartbeat every 30s. |
| Azure Communication Services | Registration confirmation and milestone notification emails (~$25/mo). Falls back to NoopSender when COMMUNICATION_SERVICE_CONNECTION_STRING is unset so dev environments boot without secrets. |
| Sportmonks API | FIFA player data polled every 2 seconds during live matches. A circuit breaker opens after 5 consecutive failures; during the open window, the api-server uses the last-good base price with a DEGRADED flag. |
| Application Insights | Telemetry at 1% trace sampling to stay under the 5 GB free tier. |