Architecture¶
System diagram¶
flowchart TB
subgraph Clients
APP[Flutter app<br/>aimgold_app]
WEB[Browser<br/>aimgold.org]
ADMIN[Admin browser<br/>admin.aimgold.org]
end
subgraph GitHub
REPO[aimgoldorg/Karat<br/>main branch]
GHA[GitHub Actions<br/>deploy-* workflows]
GHCR[(ghcr.io/aimgoldorg/*)]
end
subgraph VPS["Production VPS 200.141.2.55 (Docker Compose)"]
CADDY[Caddy 2<br/>TLS + vhost routing<br/>only published ports 80/443]
subgraph edge[aim_edge network]
API[digigold_api<br/>NestJS apps/api :3000]
ADMINAPI[digigold_admin_api<br/>NestJS apps/admin-api :3001]
PORTAL[digigold_admin_portal<br/>Next.js :3002]
SITE[aim_gold_web<br/>nginx static React]
end
subgraph data[aim_data network]
PG[(PostgreSQL 16)]
REDIS[(Redis 7)]
MINIO[(MinIO S3)]
end
subgraph mon[aim_mon network]
GRAFANA[Grafana]
PROM[Prometheus]
LOKI[Loki]
TEMPO[Tempo]
ALLOY[Alloy]
end
end
subgraph External
SAFEGOLD[SafeGold partner API]
RZP[Razorpay payments/payouts]
SMS[alots.in SMS gateway]
LE[Let's Encrypt]
end
APP -->|HTTPS /v1 + socket.io| CADDY
WEB --> CADDY
ADMIN --> CADDY
CADDY --> API
CADDY --> ADMINAPI
CADDY --> PORTAL
CADDY --> SITE
CADDY --> GRAFANA
PORTAL -->|server-side proxy + bearer| ADMINAPI
API --> PG
API --> REDIS
API --> MINIO
ADMINAPI --> PG
ADMINAPI --> REDIS
API --> SAFEGOLD
API --> RZP
API --> SMS
ADMINAPI -->|devops module| GHA
PROM -->|scrape /v1/metrics via aim_edge| API
PROM --> ADMINAPI
ALLOY -->|docker logs| LOKI
CADDY --> LE
REPO --> GHA
GHA -->|build + push| GHCR
GHA -->|SSH: backup, migrate, pull, restart| VPS
GHCR -->|docker pull| VPS
Components¶
| Component | Tech | Code path | Runs as |
|---|---|---|---|
| Customer API | NestJS 10 + Fastify, TypeORM, socket.io | aim-digigold-real/apps/api |
digigold_api (:3000) |
| Admin API | NestJS 10 + Fastify (same image, different command) |
aim-digigold-real/apps/admin-api |
digigold_admin_api (:3001) |
| Shared backend libs | config (joi), database (entities+migrations), redis, safegold, razorpay, storage (S3), common (guards, metrics, SMS), events | aim-digigold-real/libs/* |
compiled into both apps |
| Website | React 18 + Vite (built with Bun), served by nginx | aimgold-website/ |
aim_gold_web |
| Admin portal | Next.js (App Router, standalone) + NextAuth | digigold-admin/ |
digigold_admin_portal (:3002) |
| Mobile app | Flutter (bloc, go_router, dio, socket.io) | aimgold_app/ |
store releases, not on VPS |
| Edge | Caddy 2, automatic Let's Encrypt | deploy/Caddyfile |
aim_caddy (80/443) |
| Data stores | PostgreSQL 16, Redis 7, MinIO | deploy/docker-compose.yml |
digigold_postgres / digigold_redis / digigold_minio |
| Monitoring | Grafana, Prometheus, Loki, Tempo, Alloy, node-exporter, cAdvisor | monitoring/ |
mon_* containers |
| CI/CD | GitHub Actions + GHCR + SSH | .github/workflows/ |
GitHub-hosted runners |
| Future backend | Go 1.25 + Fiber (not deployed, see ADR 0012) | aim-gold-backend/ |
— |
Key flows¶
Buy gold (customer)¶
- The Flutter app calls
POST /v1/gold/buy(aimgold_app/lib/core/network/api_endpoints.dart). The website's Buy page computes quotes from the live price but does not place buys yet — the app is the wired buy client. OrdersService.createBuy(aim-digigold-real/apps/api/src/modules/orders/orders.service.ts) takes a Redis idempotency lock, gets the live SafeGold price (withrate_id), and computes the GST quote.- SafeGold
buy-gold-verifyreturns a transaction id valid for 8 minutes (libs/safegold/src/safegold.service.ts; AES-256-CBC encrypted wire format). Agold_ordersrow is savedPENDING. - A Razorpay order is created (
libs/razorpay/src/razorpay.service.ts, amount in paise, auto-capture;notes.internal_order_idlinks it back). Order →PAYMENT_INIT; the client gets the checkout intent. - Razorpay calls
POST /v1/webhooks/payment(modules/payments/payments-webhook.controller.ts). The HMAC-SHA256 signature is checked withtimingSafeEqual(libs/razorpay/src/razorpay.signature.ts); Caddy passes the body through untouched (deploy/Caddyfile). - On
payment.captured, the order is markedPAIDand anORDER_PAIDevent fires.AllocationService.allocateBuy(modules/orders/allocation.service.ts) guards the 8-minute expiry and calls SafeGoldbuy-gold-confirm; failure marks the orderFAILED. PortfolioService.applyBuyupdatesgold_holdings(balance/invested/average price) and busts the Redis portfolio cache. Order →ALLOCATED; abuy_successnotification row is written (push/SMS/email handoff is still a TODO inmodules/notifications/notifications.service.ts).
External services: Razorpay (orders + webhooks), SafeGold (price, verify, confirm, invoice link).
OTP login (customer)¶
POST /v1/auth/send-otp(public): a 6-digit OTP is generated (DEV_FIXED_OTPoverride in dev), bcrypt-hashed into Redis with a 10-minute TTL (modules/auth/otp.service.ts).- The SMS goes out through the alots.in gateway with a DLT-registered
template, sender
AIMECO(libs/common/src/sms/sms.service.ts). POST /v1/auth/verify-otp: max 3 attempts then a 30-minute Redis lockout; first-time phones auto-create a user with a referral code (modules/auth/auth.service.ts).TokenService.issueForUsersigns the access JWT ({sub, phone, deviceId}) and stores only a bcrypt hash of the random refresh token in thesessionstable (30-day expiry).POST /v1/auth/refreshrotates: old session revoked, new pair issued.- Optional layers: MPIN (bcrypt, 5-attempt lockout) and biometric login (device-bound token, hash kept in Redis keyed by user+device).
External service: alots.in SMS. The gateway credentials come from the
environment (SMS_USERNAME / SMS_APIKEY, required in production —
libs/config/src/validation.schema.ts); once set, OTP requests send live SMS.
Admin deploy (Build Tools page)¶
digigold-admin/app/(admin)/build-tools/lists recent runs viaGET /v1/devops/runs; the Deploy button (SUPER_ADMIN only) posts{target}through the token-injecting Next.js proxy (app/api/proxy/[...path]/route.ts).AdminDevopsService.triggerDeploy(apps/admin-api/src/modules/devops/devops.service.ts): server-side target→workflow allowlist, 60 s Redis lock against double-fires, GitHubworkflow_dispatchonmainusing the server-sideGITHUB_PAT, and anAuditLogrow (devops.deploy.trigger).- From there it is the standard deploy flow below.
Live price¶
SafeGold buy/sell price fetched and cached in Redis for 10 s
(libs/safegold/src/safegold.service.ts), exposed publicly at
GET /v1/price/current and broadcast every 10 s on the socket.io /price
namespace (modules/price/price.gateway.ts). Both current clients (website
store, Flutter PriceCubit) actually poll the REST endpoint; the socket feed
exists server-side but is unused by the shipped clients.
Deploy flow (push → production)¶
- Push to
main. Path filters pick the affected workflow(s) (.github/workflows/deploy-backend.yml,deploy-website.yml,deploy-admin-portal.yml,deploy-monitoring.yml). buildjob: Buildx builds the image (GHA layer cache) and pusheslatest+sha-<short>to GHCR using the workflow'sGITHUB_TOKEN.deployjob: copiesdeploy/docker-compose.yml+deploy/Caddyfileto/opt/aim/deploy(git is the source of truth; server copies are overwritten), then over SSH — serialized byflock, fail-fast — logs into GHCR and pulls the new image.- Backend only:
pg_dumpbackup into/opt/aim/backups/(last 14 kept), thennpm run migration:run:prodruns pending TypeORM migrations from the new image before any app restart. docker compose up -d --no-build <services> caddy, Caddy config reload, old-image prune.verifystep:curl --resolve <host>:443:VPS_IP https://<host>/...health checks pinned to the VPS, retried up to a minute. A red run means the deploy did not verify; previous containers keep serving.
Manual triggers: every workflow has workflow_dispatch, callable from the
admin portal's Build Tools page via POST /v1/devops/deploy
(admin-api devops module: server-side workflow allowlist, 60 s Redis lock,
audit log entry).
Tech stack¶
| Layer | Choice | Why |
|---|---|---|
| Backend | NestJS 10 + Fastify | see ADR 0003 (reconstructed) |
| Database | PostgreSQL 16 + TypeORM migrations | see ADR 0004 |
| Cache/locks | Redis 7 | see ADR 0005 |
| Object storage | MinIO (S3 API) | see ADR 0006 |
| Web | React 18 + Vite + zustand, Bun build | TODO(question): why Bun over npm for this one app? |
| Admin | Next.js standalone + NextAuth | see ADR 0009 |
| Mobile | Flutter + bloc | see ADR 0010 |
| Edge/TLS | Caddy 2 | see ADR 0007 |
| CI/CD | GitHub Actions → GHCR → SSH pull | see ADR 0008 |
| Monitoring | Grafana LGTM stack | see ADR 0011 |
Repository layout¶
Karat/
├── aim-digigold-real/ # THE deployed backend (NestJS monorepo)
│ ├── apps/api/ # customer API (:3000)
│ ├── apps/admin-api/ # admin API (:3001) + seed CLIs
│ ├── libs/ # shared: config, database, redis, safegold,
│ │ # razorpay, storage, common, events
│ └── Dockerfile # one 3-stage image for both apps
├── digigold-admin/ # Admin portal (Next.js, admin.aimgold.org)
├── aimgold-website/ # Public website (React/Vite → nginx, aimgold.org)
├── aimgold_app/ # Flutter mobile app (3 flavors, fastlane)
├── aim-gold-backend/ # Go/Fiber rewrite — NOT deployed (ADR 0012)
├── deploy/ # Production compose stack + Caddyfile + gen-env.sh
├── monitoring/ # Grafana/Prometheus/Loki/Tempo/Alloy stack
├── .github/workflows/ # ci.yml + deploy-{backend,website,admin-portal,monitoring}.yml
└── docs/ # this documentation (mkdocs)
Design constraints visible in the code¶
- Single 1-vCPU VPS: images are never built on the server; monitoring
containers carry
mem_limits; deploys areflock-serialized. - Secrets never in git: runtime secrets live only in
/opt/aim/deploy/.env.production,/opt/aim/deploy/portal.env.production, and/opt/aim/monitoring/.env(all chmod 600).gen-env.shgenerates fresh ones;.gitignoreblocks.env*. - Data network isolation: postgres/redis/minio sit on
aim_data, which Caddy and the monitoring stack never join. - Webhook raw bodies: Caddy passes bodies through untouched
(
request_bodyblocks) because Razorpay/SafeGold webhook HMAC checks need the exact bytes. api+admin_apishare one image — they always deploy together.- Portal env split: the portal container gets
portal.env.productiononly, so backend secrets never enter the frontend container.