8.4 KiB
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 and every key is in config-matrix.md.
Last verified: 2026-07-30 against commit
d3ec723,docker-compose.yml,deploy/Caddyfileandserver/src/API/Baya.Web.Api/Program.cs.
Deployed
graph LR
browser["Browser<br/>balinyaar.ir"]
subgraph net["caddy_net (external docker network)"]
caddy["Caddy<br/><i>pre-existing, not in this repo</i><br/>TLS terminator"]
web["balinyaar-web:3000<br/>Next.js 16"]
api["balinyaar-api:8080<br/>ASP.NET Core 10"]
relay["balinyaar-otp-relay:5010<br/>Node, zero deps"]
proxy["hysteria-client:8081<br/><i>pre-existing</i>"]
end
sql[("Remote SQL Server<br/>87.107.152.16:1433<br/><b>not containerised</b>")]
vol[["api-object-storage<br/>docker volume"]]
tg["api.telegram.org"]
rails["External rails<br/>PSP · BNPL · Finnotech · Neshan · مودیان<br/><i>all mocked by default</i>"]
browser -->|"HTTPS"| caddy
caddy -->|"HTTP :3000"| web
browser -->|"HTTPS api.balinyaar.ir<br/><b>every API call</b>"| caddy
caddy -->|"HTTP :8080<br/>+ 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<br/><b>no server-to-server calls</b>"| 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:<rail>:Provider — see config-matrix.md |
| 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:
StartupSecretsGuard— refuses to start on a missing or placeholder secret, before any service reads config.- A reachable SQL Server — required to start, full stop.
- 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. - 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 --trustis 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:ApiKeyfromappsettings.Development.jsoninto your owntelegram-otp-bot/.env. The value in.env.exampleis published andTelegramSmsSenderdeliberately rejects it.