Skip to content

Getting Started

Prerequisites

Tool Version Needed for
Node.js 20.x (node:20-alpine in the backend, admin-portal and account-admin Dockerfiles) backend (all three NestJS apps), admin portal, account-admin portal
npm ships with Node 20 backend (package-lock.json), portals
Bun 1.3 (oven/bun:1.3-alpine in the Dockerfile) website build
Docker + Compose Compose v2 (docker compose …, not docker-compose) local data stores, prod stack
Flutter / Dart Dart SDK ≥ 3.3 (aimgold_app/pubspec.yaml) mobile app only
Go 1.25 (aim-gold-backend/go.mod) only the parked Go rewrite

Nothing in the repository pins the production Docker or Compose version; deploy/scripts/manual-deploy.sh simply reports whatever docker --version says on the box. Run docker compose version on the VPS if you need to know.

Install and run locally

Backend (customer API + admin API)

cd aim-digigold-real
npm ci
cp .env.example .env.development   # then fill in the __FILL_ME__ values
docker compose up -d postgres redis minio   # data stores only — the compose
                                            # file also defines api/admin-api
                                            # services you do not want while
                                            # running the apps with npm
npm run migration:run         # required on a fresh database
npm run start:api             # :3000, Swagger at http://localhost:3000/docs
npm run start:admin-api       # :3001, Swagger at http://localhost:3001/docs

aim-digigold-real/.env.example is a 395-line annotated template and is the authority; the table below is a summary. Its own header says to copy it to .env.development. The minimum to boot is DB_*, REDIS_HOST, JWT_SECRET (≥ 16 chars), FIELD_ENCRYPTION_KEY (exactly 64 hex) and SAFEGOLD_BASE_URL.

migration:run is not optional

Neither app migrates on boot — libs/database/src/database.module.ts:22 sets migrationsRun: false, and DB_SYNCHRONIZE defaults to false (libs/config/src/validation.schema.ts). A fresh Postgres therefore has zero tables and every query fails on a missing relation. Skipping this step is why verify-otp 500s on a brand-new database.

docker compose up -d with no service names is deliberately not what you want here: aim-digigold-real/docker-compose.yml defines five services, not three, and both api and admin-api declare env_file: .env.development — a file that does not exist in a fresh checkout, so the project fails to load until you have copied it.

All three apps (api, admin-api, accounting-api) load .env.<NODE_ENV> then .env (apps/*/src/app.module.ts). Useful scripts: npm run lint:ci, npm test, npm run migration:generate -- libs/database/src/migrations/<Name>, npm run migration:run (TS, local) / npm run migration:run:prod (compiled). Integration tests run against real containers: cp .env.integration.example .env.integration && npm run test:integration:up && npm run test:integration (tear down with npm run test:integration:down). That throwaway Postgres binds 127.0.0.1:55432 and shares no container, volume, network or port with the dev stack, and test/integration/guard.ts refuses to run against anything non-loopback or production-looking. npm run test:portal-e2e drives the accounting-api owner-bootstrap suite.

Seed an admin user for the portal / admin API:

npm run seed:admin            # prompts for email / name / password
                              # (or ADMIN_EMAIL / ADMIN_NAME / ADMIN_PASSWORD / ADMIN_ROLE)

The password must be at least 12 characters (bcrypt cost 12). ADMIN_ROLE defaults to SUPER_ADMIN and must be one of SUPER_ADMIN | ADMIN | SUPPORT. Re-running against an email that already exists exits 2 rather than overwriting — deliberate, because a bare upsert would silently re-activate a deactivated admin. To do it anyway: SEED_ADMIN_OVERWRITE='overwrite:you@example.org' npm run seed:admin.

Accounting API (apps/accounting-api)

cd aim-digigold-real
npm run start:accounting-api   # :3003, prefix /api/v1/accounting
npm run seed:portal-owner      # one-time bootstrap owner
npm run configure:accounting   # accounting policy configuration CLI

There is no Swagger on this surface, by design: it enumerates every financial read in the business and the routes that administer access to them, so it publishes no schema at all (apps/accounting-api/src/main.ts). Writes are off unless ACCOUNTING_PORTAL_MODE=readwrite; the default readonly refuses every non-GET.

Account Admin portal (aim-gold-account-admin)

cd aim-gold-account-admin
npm ci
npm run dev                    # http://localhost:3004

With no .env.local it runs entirely on an in-browser mock. Live mode needs BOTH NEXT_PUBLIC_ACCOUNTING_API_MODE=api and NEXT_PUBLIC_API_BASE_URL pointing at the accounting API; either one missing falls back to the mock on purpose, so a half-configured build cannot look live (src/core/services/accounting-service.ts). NEXT_PUBLIC_* is inlined at build time, so changing one needs a restart — and a rebuild for images. See ADR 0014.

Website

cd aimgold-website
bun install
cp .env.example .env.local    # sets VITE_API_BASE_URL=http://localhost:3000
bun run dev                   # http://localhost:5199
bun run typecheck && bun run build

Without .env.local the dev server talks to the live https://api.aimgold.org — that is the in-code default in src/lib/api.ts:10-11, not a local one. Vite inlines VITE_* at build time, so a change needs a restart of bun run dev (and a rebuild for images).

Admin portal

cd digigold-admin
npm ci
# .env.local: NEXTAUTH_URL=http://localhost:3002, NEXTAUTH_SECRET=<random>,
#             ADMIN_API_INTERNAL_URL=http://localhost:3001
npm run dev                   # http://localhost:3002

Mobile app

cd aimgold_app
flutter pub get
# Flavor selection is Dart-side (the entry-point file). There are NO Gradle
# product flavors — passing --flavor fails with
# "task assembleDevelopmentDebug not found".
flutter run -t lib/main_development.dart   # or main_staging / main_production

development is not your local backend

The development flavor points at the live backend https://api.aimgold.org (aimgold_app/lib/flavor_config.dart). It differs from production only by the debug banner and the sandbox-payments flag — it does not talk to localhost:3000.

Production

Production is deploy-by-push for code: see Architecture → Deploy flow. Schema changes are not. .github/workflows/deploy-backend.yml prints "REFUSING TO DEPLOY: the database has pending migrations" and stops; migrations run only through the workflow_dispatch-only .github/workflows/migrate-production.yml, which requires an armed entry in deploy/production-migration-authorization.json — commit-bound, list-bound, operator-bound and single-use, enforced by aim-digigold-real/scripts/verify-migration-authorization.js. Migrations are never applied by a merge. The safe order for a release carrying one is: deploy backend (builds the image, then refuses) → migrate → deploy backend again.

When GitHub Actions is unavailable, the same gates are enforced over SSH by deploy/scripts/manual-deploy.sh, wrapped by repo-root npm scripts:

npm run deploy:preflight   # checks before anything moves
npm run deploy:status      # what is actually running (pending is relative to
                           # the deployed image)
npm run deploy:backend     # or :website / :admin / :migrate / :all / :dry-run

The stack lives on the VPS under /opt/aim/deploy; deploy/gen-env.sh generates a fresh .env.production on a new box; deploy/README.md is the ops runbook (rollback, seeding, migrations) and Backup and Recovery covers backups and the restore drill.

Configuration reference

The environment variables you are most likely to need, by component. This is a summary, not an exhaustive list: aim-digigold-real/.env.example and libs/config/src/validation.schema.ts together are the authority for the backend surface. (The parked Go rewrite documents its own surface in aim-gold-backend/.env.example.)

Secrets

Never commit values. Production values live only in /opt/aim/deploy/.env.production, /opt/aim/deploy/portal.env.production and /opt/aim/monitoring/.env on the server.

Backend (aim-digigold-real — validated by libs/config/src/validation.schema.ts)

Variable Purpose Required Example
NODE_ENV environment selector; picks .env.<NODE_ENV> no (default development) production
API_PORT customer API port no (default 3000) 3000
ADMIN_API_PORT admin API port no (default 3001) 3001
API_PUBLIC_URL public origin for absolute URLs (image proxy links) no (default https://api.aimgold.org) https://api.example.org
API_CORS_ORIGINS customer API CORS allowlist no https://aimgold.org
API_DOCS_ENABLED the only way to expose Swagger in production no false
DB_HOST Postgres host (compose overrides to digigold_postgres) yes localhost
DB_PORT Postgres port no (default 5432) 5432
DB_USER Postgres user (also POSTGRES_USER in compose + backup step) yes digigold
DB_PASSWORD Postgres password yes <random>
DB_NAME database name yes digigold
DB_SYNCHRONIZE TypeORM auto-sync — must stay false; schema is migration-managed no (default false) false
DB_LOGGING TypeORM query logging no (default false) false
REDIS_HOST Redis host (compose overrides to digigold_redis) yes localhost
REDIS_PORT Redis port no (default 6379) 6379
REDIS_PASSWORD Redis auth (also feeds --requirepass in compose) prod yes <random>
JWT_SECRET HMAC secret for all JWTs yes (min 16 chars) <64-hex>
JWT_ACCESS_TTL customer access-token TTL no (default 15m) 15m
JWT_REFRESH_TTL customer refresh-token TTL no (default 30d) 30d
ADMIN_JWT_ACCESS_TTL admin token TTL (matches 8 h portal session) no (default 8h) 8h
FIELD_ENCRYPTION_KEY AES key for PII fields — unrecoverable if lost yes (exactly 64 hex) <64-hex>
DEV_FIXED_OTP forces every generated OTP to this value. Honoured only when NODE_ENV is development or test and the value is exactly 6 digits; anywhere else it is ignored and logged as an error at boot, so a copied .env.production cannot become an auth bypass no 000000
SAFEGOLD_BASE_URL SafeGold partner API base yes (uri) https://partners-staging.safegold.com
SAFEGOLD_PROVIDER_ENV sandbox | production — must agree with SAFEGOLD_BASE_URL or the API refuses to boot no sandbox
SAFEGOLD_API_KEY SafeGold bearer key (env-only; required in production) prod yes <key>
SAFEGOLD_GOLD_TOKEN SafeGold gold-product token (env-only; required in production) prod yes <token>
SAFEGOLD_ALLOW_FAKE_PRICES dev-only: serve deterministic local prices when no SafeGold credential is configured. Ignored when NODE_ENV=production — the client will not quote a fabricated price a real order could be priced from (libs/safegold/src/safegold.service.ts:112-133). Read straight off process.env, so unlike the rest of this table it is not in validation.schema.ts and not in .env.example; add it to .env.development by hand. no true
SAFEGOLD_ENCRYPTED legacy AES-envelope wire format toggle no (default false) false
SAFEGOLD_PARTNER_ID SafeGold partner id no <id>
DIGITAL_GOLD_PAYMENT_ENABLED kill switch for paid buy flows no (default false) true
DIGITAL_SELL_ENABLED kill switch for sell flows no (default false) true
PAYMENT_PROVIDER default gateway name for the gateway registry no razorpay
PAYMENT_GATEWAYS_ENABLED comma-separated list of gateways to register no razorpay
PAYMENT_RAZORPAY_CLIENT_ID / _CLIENT_SECRET / _WEBHOOK_SECRET Razorpay credentials — the canonical namespaced form, resolved first (libs/common/src/legal/payment-credentials.ts) no (all three or none) rzp_live_…
RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET / RAZORPAY_WEBHOOK_SECRET legacy fallback names, still honoured for razorpay no (all three or none) rzp_live_…
RAZORPAY_X_KEY_ID / RAZORPAY_X_KEY_SECRET / RAZORPAY_X_ACCOUNT RazorpayX payout keys + source account no <…>
RAZORPAY_X_WEBHOOK_SECRET payout webhook HMAC secret no <secret>
KYC_PROVIDER / KYC_API_KEY / KYC_WEBHOOK_SECRET KYC provider wiring (stubbed until set) no <…>
S3_ENDPOINT S3/MinIO endpoint (compose overrides to http://digigold_minio:9000) no http://localhost:9000
S3_ACCESS_KEY / S3_SECRET_KEY S3 creds (also MinIO root creds in compose) prod yes <random>
S3_BUCKET app bucket (auto-created by minio_init) prod yes digigold
SMS_USERNAME / SMS_APIKEY alots.in SMS gateway creds (env-only; required in production; OTP sends are live once set) prod yes <…>
SMS_SENDER / SMS_TEMPLATE_ID / SMS_ROUTE / SMS_BASE_URL SMS sender id, DLT template, route, endpoint no (defaults in code) AIMECO
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS outbound email (stubbed until set) no smtp.example.com
ADMIN_PORTAL_ORIGIN CORS allowlist for admin API — unset = closed (no cross-origin) recommended https://admin.example.org
GITHUB_PAT server-side PAT powering the portal's Build Tools (503 without it; not in gen-env.sh — add manually) Build Tools only ghp_…
GITHUB_REPO repo the Build Tools target no (default aimgoldorg/Karat) org/repo
ENV_FILE dotenv override for TypeORM CLI + seed CLIs no .env.production
ADMIN_EMAIL / ADMIN_NAME / ADMIN_PASSWORD / ADMIN_ROLE seed:admin CLI inputs (prompted if unset) no
SEED_ADMIN_OVERWRITE overwrite:<email> — the only way seed:admin will touch an existing admin no overwrite:you@example.org

A half-configured gateway is a boot refusal

All three of a gateway's credentials must be set together or all left empty. validation.schema.ts ties RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET and RAZORPAY_WEBHOOK_SECRET together with an .and(…), and PaymentsGatewayModule.onModuleInit (libs/payments/src/payments-gateway.module.ts) throws on any adapter whose validateCredentials() errors. This fails closed on purpose: a partially configured gateway is worse than none. With none set the API boots normally and checkout returns 503.

Accounting API (apps/accounting-api)

Variable Purpose Required Example
ACCOUNTING_API_PORT accounting API port no (default 3003) 3003
ACCOUNTING_PORTAL_ORIGIN CORS allowlist for the Account Admin portal recommended https://accounts.example.org
ACCOUNTING_PORTAL_MODE readonly | readwrite. Only the exact string readwrite unlocks writes; the default refuses every non-GET no (default readonly) readonly
ACCOUNTING_SESSION_TTL_HOURS portal session lifetime (1–24) no (default 8) 8
ACCOUNTING_LOGIN_MAX_ATTEMPTS failed logins before lockout (3–20) no (default 5) 5
ACCOUNTING_LOCKOUT_MINUTES lockout duration (1–1440) no (default 15) 15
ACCOUNTING_PROVIDER_BALANCE_ENABLED expose provider balance reads no (default false) false
ACCOUNTING_AUTO_POSTING_ENABLED read directly by libs/finance/src/accounting/posting-gate.service.ts; must be the string true to auto-post no (default off) true

Deploy layer (deploy/docker-compose.yml, values in server .env.production)

Variable Purpose Required Example
API_IMAGE_TAG GHCR tag for the backend image — one image carries api, admin_api and accounting_api, so pinning it rolls all three back together no (default latest) sha-a1b2c3d
WEBSITE_IMAGE_TAG GHCR tag for the website image no (default latest) sha-a1b2c3d
ADMIN_PORTAL_IMAGE_TAG GHCR tag for the portal image no (default latest) sha-a1b2c3d
DOCS_IMAGE_TAG GHCR tag for the MkDocs site image (aim_docs) no (default latest) sha-a1b2c3d
ACCOUNT_ADMIN_IMAGE_TAG GHCR tag for the Account Admin portal image (accounting compose profile) no (default latest) sha-a1b2c3d

Admin portal (portal.env.production on the server; NEXT_PUBLIC_* baked at image build)

Variable Purpose Required Example
NEXTAUTH_URL portal canonical URL yes at runtime https://admin.example.org
NEXTAUTH_SECRET NextAuth session secret yes at runtime <random 32B>
ADMIN_API_INTERNAL_URL admin API base for proxy/login (compose sets container URL) no (default http://localhost:3001) http://digigold_admin_api:3001
NEXT_PUBLIC_API_WS_URL customer-API origin for the price ticker (build-time; Dockerfile bakes prod value) no https://api.example.org
NEXT_PUBLIC_BRAND_NAME brand label (build-time) no (default DigiGold) MyBrand
NEXT_PUBLIC_MONITORING_URL Monitoring link target (build-time) no (default https://monitor.aimgold.org) https://monitor.example.org
PORT / HOSTNAME / NODE_ENV / NEXT_TELEMETRY_DISABLED standalone server bind + mode (set in Dockerfile) no 3002 / 0.0.0.0

Account Admin portal (aim-gold-account-admin, build-time)

Variable Purpose Required Example
NEXT_PUBLIC_ACCOUNTING_API_MODE api switches off the in-browser mock. Both this and NEXT_PUBLIC_API_BASE_URL are required; either missing falls back to the mock no (mock without it) api
NEXT_PUBLIC_API_BASE_URL accounting API origin, inlined at build time no (mock without it) http://localhost:3003

Website (build-time only)

Variable Purpose Required Example
VITE_API_BASE_URL customer API origin inlined at build time (src/lib/api.ts:10-11). The Dockerfile declares ARG VITE_API_BASE_URL="" and .github/workflows/deploy-website.yml passes no value, so the bundle falls back to the in-code default https://api.aimgold.org. The client treats a blank or whitespace value as unset via .trim(), deliberately: a bare ?? would leave a relative /v1 and new URL() would throw on every call in the deployed image no https://api.example.org

Monitoring (/opt/aim/monitoring/.env on the server)

Variable Purpose Required Example
GRAFANA_ADMIN_USER Grafana admin login no (default admin) admin
GRAFANA_ADMIN_PASSWORD Grafana admin password — compose refuses to start without it yes <strong>
ALERT_WEBHOOK_URL POST-JSON endpoint every Grafana alert is delivered to. Unset, every provisioned rule still evaluates but reaches nobody: compose substitutes the reserved http://alert-transport-unconfigured.invalid/ so Grafana boots rather than silently half-working effectively yes https://hooks.slack.com/services/…

CI (GitHub repo secrets)

Secret Purpose
VPS_HOST / VPS_USER / VPS_SSH_KEY SSH target + key for deploys and pinned health checks
GHCR_PULL_USER / GHCR_PULL_TOKEN read:packages credentials the VPS uses to pull images
GITHUB_TOKEN auto-provided; pushes built images to GHCR

First task: run the login → price → portfolio flow locally

  1. Start the data stores and both APIs as above — including npm run migration:run — with DEV_FIXED_OTP=000000 and SAFEGOLD_ALLOW_FAKE_PRICES=true in .env.development. Without the second, step 4's /v1/price/current returns 500: the SafeGold client refuses to invent a price and local dev has no SafeGold credentials (libs/safegold/src/safegold.service.ts:112-133). It has no effect when NODE_ENV=production.
  2. Request an OTP:
    curl -X POST localhost:3000/v1/auth/send-otp \
      -H 'Content-Type: application/json' -d '{"phone":"+919999999999"}'
    
    An SMS is still attempted even with DEV_FIXED_OTP set — the fixed value only replaces the generated digits, and OtpService.send then hashes, stores and calls SmsService.sendOtp unconditionally (apps/api/src/modules/auth/otp.service.ts). The gateway error is not swallowed: AuthService.sendOtp does not catch it, so with blank credentials or no outbound network this can return 500 rather than {sent:true}. If it does, point SMS_BASE_URL at a local stub. The OTP you submit is the fixed value either way.
  3. Verify it — this auto-creates the user and returns tokens:
    curl -X POST localhost:3000/v1/auth/verify-otp \
      -H 'Content-Type: application/json' \
      -d '{"phone":"+919999999999","otp":"000000","deviceId":"dev-1"}'
    
  4. Use the accessToken:
    curl localhost:3000/v1/price/current
    curl -H "Authorization: Bearer $TOKEN" localhost:3000/v1/portfolio
    
    The portfolio summary is mounted at the collection root, not /v1/portfolio/summaryPortfolioController declares @Controller('portfolio') with a bare @Get() (apps/api/src/modules/portfolio/portfolio.controller.ts). The other routes are /v1/portfolio/history, /v1/portfolio/passbook and /v1/portfolio/statement.
  5. Explore the rest interactively via Swagger at localhost:3000/docs and localhost:3001/docs, or read the API Reference. The accounting API on :3003 publishes no schema, so it has no equivalent.