A fresh deployment came up with a populated portfolio, an empty feeds screen and no alerts, because everything downstream of the seeder was an admin call whose order lived only in a runbook. POST /admin/bootstrap/demo now runs the six steps in dependency order and reports each one. The price simulator widens from the four commodities with no feed to every active basket pair, so price_z_score gets its three-year seasonal mean and TW-05 can fire with no DATAGOV_API_KEY present at all. Levels come from a per-crop table of ordinary mandi rates and the shape from the commodity group, because the group already encodes perishability - a vegetable moves on a weather event within days and a cereal does not, and flattening that would erase the one distinction the z-score exists to read. A pair holding any non-simulated row is skipped unless forced. That ordering is the safety property: run this after a real ingestion and it fills the gaps around the real series rather than over them. The orchestrator is deliberately not transactional. The seeder holds one transaction across its whole run, and when the machine slept mid-run nothing committed until morning while the console confidently served pre-seed state. A bootstrap generating three years of prices across every pair is long enough to hit exactly that, so each step commits alone and a failure in one leaves the others standing. Still not trainable, and coverage still says so. The seeder derives DPD from the band and the UPI simulator derives inflow from the same band, so a model fitted here recovers the seeder rule and reports fine metrics for doing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
Markdown
295 lines
11 KiB
Markdown
# Deploying UdyamLinc
|
|
|
|
Two artifacts, one host.
|
|
|
|
| Artifact | Built from | Output |
|
|
|---|---|---|
|
|
| API | `UdyamLinc-Backend` | `target/udyamlinc-backend.jar` (~80 MB, executable) |
|
|
| UI | `UdyamLinc-UI` | `dist/` (static files, ~880 kB) |
|
|
|
|
The UI bundle is built with `VITE_UDYAM_API_BASE_URL=/api/v1/udyam` — a **relative**
|
|
path. It therefore contains no hostname, serves correctly from any domain, and is
|
|
same-origin with the API. That last point is the one worth keeping: same-origin means
|
|
CORS never applies, and CORS is the single largest source of "worked locally, broken on
|
|
the server" in a split deployment.
|
|
|
|
The only requirement on the web server is that it does two things:
|
|
|
|
1. serve `dist/` with an SPA fallback — unknown paths return `index.html`, because
|
|
React Router owns `/officer/**`, `/nabard/**` and `/app/**`, not the file system;
|
|
2. pass `/api/**` through to the Spring Boot process on port 8082.
|
|
|
|
Any of nginx, Apache httpd or Tomcat can do both. Pick the section that matches your box.
|
|
|
|
---
|
|
|
|
## 1. The API (identical in all three cases)
|
|
|
|
The jar embeds Tomcat 11. It does not need an application server installed, and it does
|
|
not go in `webapps/`.
|
|
|
|
```bash
|
|
java -jar udyamlinc-backend.jar
|
|
```
|
|
|
|
Configuration is entirely by environment variable — nothing needs editing inside the jar:
|
|
|
|
```bash
|
|
export UDYAM_DB_URL=jdbc:postgresql://localhost:5432/UdyamLinc_DEMO
|
|
export UDYAM_DB_USERNAME=postgres
|
|
export UDYAM_DB_PASSWORD=<the real one>
|
|
|
|
# Set this. The default is committed to the repository, so it is public, and a
|
|
# signing key anyone can read signs anyone's token.
|
|
export UDYAM_JWT_SECRET=<a long random string>
|
|
```
|
|
|
|
`spring.jpa.hibernate.ddl-auto=update` builds the schema on first boot, so the database
|
|
only has to exist and be empty:
|
|
|
|
```sql
|
|
CREATE DATABASE "UdyamLinc_DEMO";
|
|
```
|
|
|
|
### As a systemd service
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=UdyamLinc API
|
|
After=network.target postgresql.service
|
|
|
|
[Service]
|
|
User=udyamlinc
|
|
WorkingDirectory=/opt/udyamlinc
|
|
Environment=UDYAM_DB_URL=jdbc:postgresql://localhost:5432/UdyamLinc_DEMO
|
|
Environment=UDYAM_DB_USERNAME=postgres
|
|
Environment=UDYAM_DB_PASSWORD=change-me
|
|
Environment=UDYAM_JWT_SECRET=change-me
|
|
ExecStart=/usr/bin/java -jar /opt/udyamlinc/udyamlinc-backend.jar
|
|
SuccessExitStatus=143
|
|
Restart=on-failure
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
### First boot takes a while, and the failure mode is silent
|
|
|
|
`UdyamDataSeeder` populates 263 enterprises across 8 partner banks and 16 branches, and
|
|
it runs as **one transaction**. Nothing is visible in the database until it commits.
|
|
|
|
This has bitten us once already: the machine slept mid-seed, the transaction stayed open
|
|
overnight, and every figure read from the console during that window was the pre-seed
|
|
state — reported confidently, with no warning anywhere. **Watch for the seeder's final
|
|
log line before trusting any number on screen**, and check its timestamp rather than
|
|
just its text.
|
|
|
|
Verify the API is up:
|
|
|
|
```bash
|
|
curl -s localhost:8082/api/v1/udyam/auth/regional-offices
|
|
```
|
|
|
|
That endpoint is deliberately public — the login page reads it before anyone has signed
|
|
in — so it is a clean liveness check.
|
|
|
|
---
|
|
|
|
## 2a. Front door: nginx
|
|
|
|
`UdyamLinc-UI/nginx.conf` is ready to use as-is. Copy `dist/` to the document root and
|
|
the conf to `/etc/nginx/conf.d/`. It already carries the `/api/` proxy block, the SPA
|
|
fallback, gzip, and asset caching.
|
|
|
|
Adjust `listen 5173` to `80`/`443` for a real deployment.
|
|
|
|
---
|
|
|
|
## 2b. Front door: Apache httpd
|
|
|
|
Enable the modules first — on a stock install `proxy_http` and `rewrite` are usually off:
|
|
|
|
```bash
|
|
a2enmod proxy proxy_http rewrite headers
|
|
```
|
|
|
|
Virtual host:
|
|
|
|
```apache
|
|
<VirtualHost *:80>
|
|
ServerName udyamlinc.lincpay.tech
|
|
DocumentRoot /var/www/udyamlinc
|
|
|
|
# ── API ─────────────────────────────────────────────
|
|
# The backend's context-path is already /api/v1/udyam, so the prefix passes
|
|
# through unchanged rather than being rewritten.
|
|
ProxyPreserveHost On
|
|
ProxyPass /api/ http://127.0.0.1:8082/api/
|
|
ProxyPassReverse /api/ http://127.0.0.1:8082/api/
|
|
|
|
# Ingestion and the three-year backfill are long-running admin calls. The
|
|
# default proxy timeout cuts them off mid-run and reports a gateway error
|
|
# over a job that is still working.
|
|
ProxyTimeout 300
|
|
|
|
<Directory /var/www/udyamlinc>
|
|
Require all granted
|
|
Options -Indexes
|
|
|
|
# ── SPA fallback ────────────────────────────────
|
|
# Anything that is not a real file, not a real directory, and not the
|
|
# API is handed to index.html for React Router to resolve. Without the
|
|
# !^/api/ guard this rule swallows API calls and returns HTML to axios,
|
|
# which surfaces as a JSON parse error rather than a routing problem.
|
|
RewriteEngine On
|
|
RewriteCond %{REQUEST_URI} !^/api/
|
|
RewriteCond %{REQUEST_FILENAME} !-f
|
|
RewriteCond %{REQUEST_FILENAME} !-d
|
|
RewriteRule . /index.html [L]
|
|
</Directory>
|
|
|
|
# Fingerprinted assets are immutable; index.html must never be cached or a
|
|
# deploy cannot reach browsers that already have it.
|
|
<LocationMatch "^/assets/">
|
|
Header set Cache-Control "public, max-age=2592000, immutable"
|
|
</LocationMatch>
|
|
<LocationMatch "^/index\.html$">
|
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
|
</LocationMatch>
|
|
</VirtualHost>
|
|
```
|
|
|
|
---
|
|
|
|
## 2c. Front door: Tomcat
|
|
|
|
Two things are worth separating here, because "Tomcat" can mean either.
|
|
|
|
### Serving the UI from Tomcat — fine
|
|
|
|
Copy `dist/` into `$CATALINA_HOME/webapps/ROOT/` and add the SPA fallback via the
|
|
rewrite valve. In `conf/Catalina/localhost/ROOT.xml` (or the `<Context>` element):
|
|
|
|
```xml
|
|
<Context>
|
|
<Valve className="org.apache.catalina.valves.rewrite.RewriteValve" />
|
|
</Context>
|
|
```
|
|
|
|
with `webapps/ROOT/WEB-INF/rewrite.config`:
|
|
|
|
```
|
|
RewriteCond %{REQUEST_URI} !^/api/
|
|
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
|
|
RewriteRule ^(.*)$ /index.html
|
|
```
|
|
|
|
The API still runs as its own `java -jar` process on 8082, and Tomcat proxies to it —
|
|
or you put httpd/nginx in front of both.
|
|
|
|
### Deploying the API *as a WAR* into Tomcat — do not do this today
|
|
|
|
It is possible but it is not a same-day change, and the constraint is hard rather than
|
|
stylistic:
|
|
|
|
- This project is **Spring Boot 4.0.1**, which is built on Jakarta EE 11 / Servlet 6.1.
|
|
That requires **Tomcat 11**. On Tomcat 9 or 10 the application does not start —
|
|
Tomcat 9 is `javax.*` and cannot load a `jakarta.*` application at all.
|
|
- It needs `<packaging>war</packaging>`, `spring-boot-starter-tomcat` moved to
|
|
`provided`, and `UdyamLincApplication` extending `SpringBootServletInitializer`.
|
|
- The context path moves from the configured `/api/v1/udyam` to the WAR's deployed
|
|
name, so the proxy rules above stop matching.
|
|
|
|
The jar already embeds Tomcat 11, so running it directly gives the same container with
|
|
none of that risk. **Recommendation: keep the jar.**
|
|
|
|
---
|
|
|
|
## Checklist before showing anyone
|
|
|
|
- [ ] `curl localhost:8082/api/v1/udyam/auth/regional-offices` returns JSON
|
|
- [ ] The seeder's final log line has appeared, with a plausible timestamp
|
|
- [ ] The site loads, and a hard refresh on a deep link such as `/nabard/geography`
|
|
returns the app rather than a 404 — this is the SPA fallback, and it is the
|
|
single most common thing to get wrong
|
|
- [ ] The browser network tab shows `/api/v1/udyam/...` calls returning 200, not HTML
|
|
- [ ] `UDYAM_JWT_SECRET` is not the committed default
|
|
|
|
## Populating a fresh database — the demo sequence
|
|
|
|
The seeder writes the portfolio, the ledger and the forecasts. It does **not** write
|
|
loan history, UPI inflows or the no-feed price series: those are admin calls, so a fresh
|
|
database comes up without them and several screens look thinner than they should.
|
|
|
|
**One call does all of it.** After the seeder's final log line:
|
|
|
|
```bash
|
|
BASE=http://localhost:8082/api/v1/udyam
|
|
TOKEN=<a supervisor token from POST $BASE/auth/login>
|
|
|
|
curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/bootstrap/demo?months=24"
|
|
```
|
|
|
|
That runs six steps in dependency order and reports each one:
|
|
|
|
| Step | What it writes |
|
|
|---|---|
|
|
| `loan-history` | 24 monthly DPD snapshots per loan, `SEEDED_HISTORY` |
|
|
| `upi-inflows` | 26 weeks per enterprise, `SIMULATED` — the Account Aggregator stand-in |
|
|
| `prices-no-feed` | milk, egg, fodder, sugarcane — the four with no API anywhere |
|
|
| `prices-all` | three years for **every remaining basket pair**, so `price_z_score` has a seasonal mean and TW-05 can fire without a data.gov.in key |
|
|
| `features` | the 15 derivations across the book |
|
|
| `scoring` | stress scores, bands, trip-wires, alerts |
|
|
|
|
It is **idempotent** — re-running is safe, and each step commits on its own, so a
|
|
failure in one leaves the others intact and the response names which failed.
|
|
|
|
The individual endpoints remain if you want to run a stage on its own:
|
|
`/admin/loan-history/generate`, `/admin/upi/simulate`,
|
|
`/admin/ingestion/simulate/no-feed`, `/admin/ingestion/simulate/all`,
|
|
`/admin/features/compute`, `/admin/scoring/run`.
|
|
|
|
Verify with the coverage endpoints, both of which report synthetic and real separately:
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer $TOKEN" "$BASE/admin/loan-history/coverage"
|
|
curl -H "Authorization: Bearer $TOKEN" "$BASE/admin/upi/coverage"
|
|
```
|
|
|
|
### All of it is labelled, and none of it can overwrite a fact
|
|
|
|
Every row written above carries its provenance in the table — `SEEDED_HISTORY` on loan
|
|
records, `SIMULATED` on UPI and price rows — and the simulators refuse to overwrite a
|
|
non-simulated row even when asked to. Real data can land beside this at any time and
|
|
stays distinguishable forever. The console surfaces the distinction rather than leaving
|
|
it to a slide.
|
|
|
|
Two consequences to state out loud rather than discover later:
|
|
|
|
- **`loan-history/coverage` reports `trainable: false`** while no real extract exists,
|
|
and that is correct. The seeder derives DPD from the risk band and the UPI simulator
|
|
derives inflow drift from the same band, so a model trained on this would learn the
|
|
seeder's own rule and validate beautifully while knowing nothing. The synthetic data
|
|
is here to exercise the pipeline and fill the console, not to train.
|
|
- **No external dependency at all.** The bootstrap needs no `DATAGOV_API_KEY`, no
|
|
Account Aggregator consent and no bank connection: every screen fills from generated
|
|
data. If a key *is* present, run the real ingestion first and the price simulator will
|
|
skip any pair that already holds real rows, filling the gaps around the real series
|
|
rather than across it.
|
|
|
|
## What is deliberately off
|
|
|
|
`udyam.ingestion.enabled` is `false`, so a demo boot never calls a government endpoint,
|
|
and `DATAGOV_API_KEY` is unset. The portfolio is seeded, which is enough for every
|
|
screen. To refresh from live sources, set the key and run, in order:
|
|
|
|
```
|
|
POST /admin/ingestion/run
|
|
POST /admin/features/compute
|
|
POST /admin/scoring/run
|
|
```
|
|
|
|
Space bulk ingestion runs a couple of minutes apart — data.gov.in meters by quota per
|
|
window, so a run started too soon after another returns 429 on every pair at once and
|
|
records the whole ingestion as failed with zero rows.
|