Skip to content

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)

  1. 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.
  2. OrdersService.createBuy (aim-digigold-real/apps/api/src/modules/orders/orders.service.ts) takes a Redis idempotency lock, gets the live SafeGold price (with rate_id), and computes the GST quote.
  3. SafeGold buy-gold-verify returns a transaction id valid for 8 minutes (libs/safegold/src/safegold.service.ts; AES-256-CBC encrypted wire format). A gold_orders row is saved PENDING.
  4. A Razorpay order is created (libs/razorpay/src/razorpay.service.ts, amount in paise, auto-capture; notes.internal_order_id links it back). Order → PAYMENT_INIT; the client gets the checkout intent.
  5. Razorpay calls POST /v1/webhooks/payment (modules/payments/payments-webhook.controller.ts). The HMAC-SHA256 signature is checked with timingSafeEqual (libs/razorpay/src/razorpay.signature.ts); Caddy passes the body through untouched (deploy/Caddyfile).
  6. On payment.captured, the order is marked PAID and an ORDER_PAID event fires. AllocationService.allocateBuy (modules/orders/allocation.service.ts) guards the 8-minute expiry and calls SafeGold buy-gold-confirm; failure marks the order FAILED.
  7. PortfolioService.applyBuy updates gold_holdings (balance/invested/average price) and busts the Redis portfolio cache. Order → ALLOCATED; a buy_success notification row is written (push/SMS/email handoff is still a TODO in modules/notifications/notifications.service.ts).

External services: Razorpay (orders + webhooks), SafeGold (price, verify, confirm, invoice link).

OTP login (customer)

  1. POST /v1/auth/send-otp (public): a 6-digit OTP is generated (DEV_FIXED_OTP override in dev), bcrypt-hashed into Redis with a 10-minute TTL (modules/auth/otp.service.ts).
  2. The SMS goes out through the alots.in gateway with a DLT-registered template, sender AIMECO (libs/common/src/sms/sms.service.ts).
  3. 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).
  4. TokenService.issueForUser signs the access JWT ({sub, phone, deviceId}) and stores only a bcrypt hash of the random refresh token in the sessions table (30-day expiry). POST /v1/auth/refresh rotates: old session revoked, new pair issued.
  5. 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)

  1. digigold-admin/app/(admin)/build-tools/ lists recent runs via GET /v1/devops/runs; the Deploy button (SUPER_ADMIN only) posts {target} through the token-injecting Next.js proxy (app/api/proxy/[...path]/route.ts).
  2. AdminDevopsService.triggerDeploy (apps/admin-api/src/modules/devops/devops.service.ts): server-side target→workflow allowlist, 60 s Redis lock against double-fires, GitHub workflow_dispatch on main using the server-side GITHUB_PAT, and an AuditLog row (devops.deploy.trigger).
  3. 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)

  1. 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).
  2. build job: Buildx builds the image (GHA layer cache) and pushes latest + sha-<short> to GHCR using the workflow's GITHUB_TOKEN.
  3. deploy job: copies deploy/docker-compose.yml + deploy/Caddyfile to /opt/aim/deploy (git is the source of truth; server copies are overwritten), then over SSH — serialized by flock, fail-fast — logs into GHCR and pulls the new image.
  4. Backend only: pg_dump backup into /opt/aim/backups/ (last 14 kept), then npm run migration:run:prod runs pending TypeORM migrations from the new image before any app restart.
  5. docker compose up -d --no-build <services> caddy, Caddy config reload, old-image prune.
  6. verify step: 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 are flock-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.sh generates fresh ones; .gitignore blocks .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_body blocks) because Razorpay/SafeGold webhook HMAC checks need the exact bytes.
  • api + admin_api share one image — they always deploy together.
  • Portal env split: the portal container gets portal.env.production only, so backend secrets never enter the frontend container.