9.3 KiB
Deploying Balinyaar
A first, shareable deployment of the whole stack under balinyaar.ir, in Docker, behind an existing Caddy reverse proxy that terminates TLS.
This file is the deploy procedure. The runtime dependency graph — every edge, what breaks when it is down, and where it is configured — is archive/docs/integration/topology.md (archived reference, not actively maintained), and every configuration key on both sides is archive/docs/integration/config-matrix.md. Read those to answer "what talks to what" or "where is this value set"; read this one to actually ship.
| Host | Serves | Container |
|---|---|---|
balinyaar.ir, www.balinyaar.ir |
Next.js web client | balinyaar-web:3000 |
api.balinyaar.ir |
ASP.NET Core API | balinyaar-api:8080 |
| (internal only) | Telegram OTP relay | balinyaar-otp-relay:5010 |
The database is not containerised — it is the remote SQL Server already configured in
server/src/API/Baya.Web.Api/appsettings.Development.json.
Nothing needs to be provisioned for it; the API just needs network reach to 87.107.152.16:1433.
Configuration model
There is no dotnet user-secrets any more. The <UserSecretsId> was removed from
Baya.Web.Api.csproj, so the API no longer reads that store at all — a stale secrets.json on a dev
machine is now inert and can be deleted. Every value lives in a file in the repo:
| What | Where |
|---|---|
| API config + secrets (DB, JWE keys, field-encryption keys, Telegram key, CORS, trusted proxies) | server/src/API/Baya.Web.Api/appsettings.Development.json |
| The two values that differ between a laptop and the container network | docker-compose.yml → api.environment |
| Client build-time config (API URL, site origin) | client/.env.production |
| Telegram relay config (bot token, chat ids, API key, proxy) | docker-compose.yml → otp-relay.environment |
The placeholder string SET_VIA_USER_SECRETS_OR_ENV in the base appsettings.json names that removed
store; the name is a historical artifact, kept only because it is the sentinel StartupSecretsGuard
rejects. The mechanism is appsettings files and environment variables — see
archive/docs/integration/config-matrix.md, which lists every key,
its default, and who reads it.
The API runs as ASPNETCORE_ENVIRONMENT=Development, so appsettings.Development.json is the file
that actually loads. There is no appsettings.Production.json in the repo at all, and adding one would
be ignored until the environment name changes too — put changes in the Development file, or change the
environment name first.
The relay's shared secret appears twice and the two must match: Seams:Sms:Telegram:ApiKey in the
appsettings file and API_KEY in the compose file. It was rotated away from the value in
telegram-otp-bot/.env.example, which is published in git and in that project's README —
TelegramSmsSender now refuses to authenticate with it. If you run the relay locally, copy the
appsettings value into your own telegram-otp-bot/.env.
⚠️
Seams:FieldEncryption:Keyand:HashKeymust never change. Every encrypted column in that database — phone numbers, addresses, IBANs, clinical notes — was written with those exact values, andusers.PhoneHash, which every login looks up, is derived fromHashKey. Rotating either makes the existing data unreadable and locks every account out. The JWE keys (IdentitySettings:SecretKey/Encryptkey) are safe to rotate; doing so only signs everyone out.
What running as Development means
This was a deliberate choice so the demo and lifecycle seeders populate the shared database and the screens aren't empty. It has real consequences, all of which are fine for a pre-launch demo among people you trust, and none of which are acceptable once strangers can reach the site:
- The developer exception page is public. Any unhandled 500 on
api.balinyaar.irreturns a stack trace and configuration detail to the caller. GET /api/v1/dev/last_otp/{phone}is live. Anyone who knows a registered phone number can read its login code and sign in as that user. This is the single biggest exposure.- Swagger is served at
api.balinyaar.ir/swagger. - The seeders re-run on every container boot (idempotent, so this is safe — they no-op on data that already exists) and migrations auto-apply on boot rather than as a separate step.
- gRPC reflection is enabled, and the demo
bookings/convertpayment-capture simulator is wired.
Going to Production later
- Set
ASPNETCORE_ENVIRONMENT: Productionindocker-compose.yml. - Create
appsettings.Production.json(it does not exist) with the same content as the Development file, but with realIdentitySettings:SecretKey/Encryptkey—StartupSecretsGuardrejects anything containingnot-for-productionoutside Development, so the current dev keys will refuse to boot (by design). KeepSeams:FieldEncryptionbyte-identical. - Run migrations as a one-shot instead of on boot:
docker compose run --rm api dotnet Baya.Web.Api.dll migrate - Swap the OTP rail:
Seams:Sms:Provider→kavenegar, withSeams:Sms:ApiKey/Senderfilled in. The Telegram relay broadcasts every code to a fixed recipient list, which stops being acceptable the moment someone outside that list can request one.
First deploy
1. Confirm the Caddy network exists
The compose file joins caddy_net as an external network — it does not create it.
docker network ls | grep caddy_net
2. Add the Balinyaar block to your Caddyfile
Copy from deploy/Caddyfile into the Caddyfile your Caddy container already loads:
balinyaar.ir, www.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-web:3000
}
api.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-api:8080
}
Caddy obtains and renews the certificates for both hostnames itself. Reload it:
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
3. Point DNS at the host
balinyaar.ir, www.balinyaar.ir and api.balinyaar.ir all need an A record on the server's public IP
before Caddy can complete the ACME challenge.
4. Confirm the proxy container is up
The relay's hop to api.telegram.org is filtered in Iran and goes out through the proxy already on
caddy_net, configured as TELEGRAM_PROXY_URL: http://hysteria-client:8081. If that container has a
different name or port, change it in docker-compose.yml — a wrong value fails the relay at boot with a
clear message rather than silently per-OTP.
5. Build and start
docker compose up -d --build
docker compose ps
docker compose logs -f api
The API's first boot applies any pending migrations and runs the seeders against the remote database, so it takes noticeably longer than later ones.
Verifying
curl https://api.balinyaar.ir/healthz/live # process is up
curl https://api.balinyaar.ir/healthz/ready # + database and object storage reachable
curl -I https://balinyaar.ir # the public landing page
docker compose logs otp-relay | head # should print the bot's @username and the proxy label
A full login round-trip is the real check: request an OTP from the site and confirm the code arrives in
the Telegram chat. If it doesn't, docker compose logs otp-relay names the failing hop — a proxy error
and a Telegram API rejection look different.
Redeploying
git pull
docker compose up -d --build
Rebuild the client whenever a NEXT_PUBLIC_* value in client/.env.production changes — those are
compiled into the browser bundle, so restarting the container alone changes nothing.
Known wrinkle: the client lockfile is Windows-generated
client/package-lock.json is produced on Windows, where npm filters out wasm32-only optional packages
and therefore never records their transitive dependencies (@emnapi/core, @emnapi/runtime). On Linux
npm does want them, so a bare npm ci fails with:
npm error `npm ci` can only install packages when your package.json and package-lock.json ... are in sync.
npm error Missing: @emnapi/runtime@1.11.3 from lock file
The client Dockerfile works around this by completing the lock inside the image before installing. To fix it permanently, regenerate the lock on Linux once and commit the result:
cd client
docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install --package-lock-only --no-audit --no-fund
Then drop the npm install --package-lock-only line from client/Dockerfile, leaving just npm ci.
Note --omit=optional is not a valid shortcut here: Turbopack resolves @parcel/watcher's native
binary through optionalDependencies, so omitting them breaks next build with
No prebuild or local build of @parcel/watcher found.
Persisted state
Two named volumes survive rebuilds. Uploaded verification documents live in the first one; losing it means the admin verification queue shows broken documents.
| Volume | Holds |
|---|---|
api-object-storage |
Uploaded verification documents (local-disk IObjectStorage seam) |
api-logs |
Serilog JSON file sink |