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¶
- Start the data stores and both APIs as above — including
npm run migration:run— withDEV_FIXED_OTP=000000andSAFEGOLD_ALLOW_FAKE_PRICES=truein.env.development. Without the second, step 4's/v1/price/currentreturns 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 whenNODE_ENV=production. - Request an OTP:
An SMS is still attempted even with
curl -X POST localhost:3000/v1/auth/send-otp \ -H 'Content-Type: application/json' -d '{"phone":"+919999999999"}'DEV_FIXED_OTPset — the fixed value only replaces the generated digits, andOtpService.sendthen hashes, stores and callsSmsService.sendOtpunconditionally (apps/api/src/modules/auth/otp.service.ts). The gateway error is not swallowed:AuthService.sendOtpdoes not catch it, so with blank credentials or no outbound network this can return 500 rather than{sent:true}. If it does, pointSMS_BASE_URLat a local stub. The OTP you submit is the fixed value either way. - 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"}' - Use the
accessToken:The portfolio summary is mounted at the collection root, notcurl localhost:3000/v1/price/current curl -H "Authorization: Bearer $TOKEN" localhost:3000/v1/portfolio/v1/portfolio/summary—PortfolioControllerdeclares@Controller('portfolio')with a bare@Get()(apps/api/src/modules/portfolio/portfolio.controller.ts). The other routes are/v1/portfolio/history,/v1/portfolio/passbookand/v1/portfolio/statement. - Explore the rest interactively via Swagger at
localhost:3000/docsandlocalhost:3001/docs, or read the API Reference. The accounting API on:3003publishes no schema, so it has no equivalent.