# Topology — the runtime dependency graph What talks to what at runtime, what breaks when each hop is down, and which file configures it. This is the answer to a deploy question; the deploy *procedure* is [DEPLOY.md](../../DEPLOY.md) and every key is in [config-matrix.md](config-matrix.md). > Last verified: 2026-07-30 against commit `d3ec723`, `docker-compose.yml`, `deploy/Caddyfile` and > `server/src/API/Baya.Web.Api/Program.cs`. --- ## Deployed ```mermaid graph LR browser["Browser
balinyaar.ir"] subgraph net["caddy_net (external docker network)"] caddy["Caddy
pre-existing, not in this repo
TLS terminator"] web["balinyaar-web:3000
Next.js 16"] api["balinyaar-api:8080
ASP.NET Core 10"] relay["balinyaar-otp-relay:5010
Node, zero deps"] proxy["hysteria-client:8081
pre-existing"] end sql[("Remote SQL Server
87.107.152.16:1433
not containerised")] vol[["api-object-storage
docker volume"]] tg["api.telegram.org"] rails["External rails
PSP · BNPL · Finnotech · Neshan · مودیان
all mocked by default"] browser -->|"HTTPS"| caddy caddy -->|"HTTP :3000"| web browser -->|"HTTPS api.balinyaar.ir
every API call"| caddy caddy -->|"HTTP :8080
+ X-Forwarded-For"| api api -->|"TCP 1433"| sql api -->|"HTTP + X-Api-Key"| relay api --> vol api -.->|"per seam config"| rails relay -->|"HTTP CONNECT"| proxy proxy --> tg web -.->|"builds absolute URLs only
no server-to-server calls"| api ``` **The single most important edge is the dotted one.** The browser calls the API directly at `https://api.balinyaar.ir`; the `web` container does **not** proxy or fetch on the browser's behalf. That is why `NEXT_PUBLIC_API_URL` must be the **public hostname**, never the container name — a container name is unresolvable from a browser. It is also why every `NEXT_PUBLIC_*` change needs an image rebuild: the value is compiled into the bundle. ## Every edge | # | From → To | Carries | Breaks if down | Configured in | | --- | --- | --- | --- | --- | | 1 | browser → Caddy | All HTTPS for both hostnames | Everything. Caddy is the only TLS terminator and the only published port | `deploy/Caddyfile` (pasted into the pre-existing Caddy) | | 2 | Caddy → `balinyaar-web:3000` | The Next.js app shell, RSC payloads, static assets | The site does not load. **The API keeps working** — they are independent hostnames | `Caddyfile` · `docker-compose.yml` | | 3 | browser → Caddy → `balinyaar-api:8080` | Every `/api/v1/*` call, bearer-authenticated | The site loads and every screen shows its error state. No data, no login | `NEXT_PUBLIC_API_URL` (build-time) · `Cors:AllowedOrigins` | | 4 | Caddy → API, `X-Forwarded-For` | The real client IP | The rate limiter partitions every request onto **Caddy's** IP, so one noisy client 429s everyone | `ForwardedHeaders:KnownNetworks` — the docker bridge ranges | | 5 | API → remote SQL Server | All application data + the Serilog sink | **Total outage.** `/healthz/ready` fails and the API will not start | `ConnectionStrings:SqlServer` / `:logDb` | | 6 | API → `balinyaar-otp-relay:5010` | OTP codes, `X-Api-Key` authenticated | **Nobody can log in.** Every other authenticated screen keeps working for existing sessions | `Seams__Sms__Telegram__BaseUrl` (compose) · `Seams:Sms:Telegram:ApiKey` | | 7 | relay → `hysteria-client:8081` → Telegram | The code delivery itself | Same as 6 — codes are generated but never arrive. Fails **at relay boot** with a clear message, not silently per-OTP | `TELEGRAM_PROXY_URL` | | 8 | API → `api-object-storage` volume | Verification documents, avatars | Uploads fail; `/healthz/ready` fails (it does a real write probe). **Without the volume, existing documents vanish on the next `up --build`** | `Seams__ObjectStorage__RootPath` + the named volume | | 9 | API → external rails | Payments, BNPL, KYC, geocoding, e-invoicing | **Nothing, by default** — every rail is `mock`. Real behaviour begins the moment a `Provider` selector changes | `Seams::Provider` — see [config-matrix.md](config-matrix.md#the-seam-selectors) | | 10 | PSP / BNPL / transferor → API webhooks | Payment capture, BNPL settlement, payout reconciliation | Payments are taken but **bookings are never confirmed** — the webhook is what creates the booking | `webhook` rate-limit policy, anonymous routes, per-provider signing secrets | ## Ports | Port | Where it applies | Note | | --- | --- | --- | | `443` | The host | Caddy. **The only published port on the machine** | | `3000` | `caddy_net` only | Next.js. Not published | | `8080` | `caddy_net` only | The API **in-container** | | `5002` | A developer laptop only | The local `launchSettings.json` port — **plain HTTP**, and nothing in the deployment uses it | | `5010` | `caddy_net` only | The OTP relay | | `1433` | Outbound to `87.107.152.16` | Remote SQL Server | > **5002 vs 8080 catches people out.** Most prose in this repo says 5002 because that is the local port. > The container binds 8080 (the ASP.NET default in a container) and the Caddyfile points there. ## Startup order `depends_on: [otp-relay]` puts the relay before the API, but that only orders *container start*, not readiness. What actually gates the API's boot: 1. **`StartupSecretsGuard`** — refuses to start on a missing or placeholder secret, before any service reads config. 2. **A reachable SQL Server** — required to start, full stop. 3. **In Development** (which is what the deployment runs): apply migrations, then seed default users, payment gateways, the demo world and the demo lifecycle. All idempotent — a re-boot logs `Demo world already seeded — no-op.` **First boot takes ~40 s** against the remote DB. 4. **When deployed as a non-Development environment**: DDL is a separate one-shot (`dotnet Baya.Web.Api.dll migrate`); boot only *checks* the schema is current and fails fast on a pending migration, then seeds roles. The relay refuses to start without `API_KEY` (min 16 chars) or with an unreachable proxy — both fail loudly at boot rather than per-request. ## Request pipeline inside the API Order matters, and two placements are deliberate: ``` UseForwardedHeaders ← first, so the resolved client IP is in place before anything reads it UseSwaggerAndUi UseRouting UseCors ← after routing, BEFORE the rate limiter and auth, so a pre-flight OPTIONS UseRateLimiter is answered rather than rejected as 429 or 401 UseAuthentication UseAuthorization MapControllers UseMetrics · UseHealthChecks ConfigureGrpcPipeline ``` ## What is not in the graph | | Why | | --- | --- | | A database container | The DB is remote and pre-provisioned. `RUNBOOK.md`'s local-SQL-in-Docker path describes a **different world** — an unseeded one (contradiction **C-5**) | | Redis | `ICacheService` / `IDistributedLock` are single-process today. When a multi-instance deployment needs a real one, a `redis` readiness check gets added alongside it | | A job runner | The weekly payout batch is generated by an **in-process** `IRecurringJob` scheduler. No external cron, no queue | | A message broker | Nothing is asynchronous across a process boundary | | An OTLP collector | `OpenTelemetry:Otlp:Endpoint` is unset, so nothing is exported. Prometheus scrapes `/metrics` directly | | A second API instance | Single instance. Multi-instance needs the distributed lock to become real first | ## Local development Same code, a different graph — no Caddy, no containers, and the **same remote database**: ``` localhost:3000 (npm run dev) ──▸ localhost:5002 (dotnet run, plain HTTP) │ ├──▸ 87.107.152.16:1433 (the same remote DB) └──▸ 127.0.0.1:5010 (the relay, if you run it) ``` Three things to know before the first run: - **The API is plain HTTP locally.** `dotnet dev-certs https --trust` is not needed and there is no certificate to trust (contradiction **C-4**). - **You share the deployment's database.** Booting in Development migrates and seeds against it. The seeders are idempotent, but you and the demo site are looking at the same rows. - **If you run the relay locally**, copy `Seams:Sms:Telegram:ApiKey` from `appsettings.Development.json` into your own `telegram-otp-bot/.env`. The value in `.env.example` is published and `TelegramSmsSender` deliberately rejects it.