386 lines
14 KiB
Markdown
386 lines
14 KiB
Markdown
# Deploying UdyamLinc
|
|
|
|
Two artifacts, one host.
|
|
|
|
| Artifact | Built from | Output |
|
|
|---|---|---|
|
|
| API | `UdyamLinc-Backend` | `target/udyamlinc-backend.war` (~80 MB) |
|
|
| UI | `UdyamLinc-UI` | `dist/` (static files, ~880 kB) |
|
|
|
|
**The war is also executable.** `spring-boot-maven-plugin` repackages it with the
|
|
provided-scope Tomcat under `WEB-INF/lib-provided`, so one artifact both runs on its own
|
|
and deploys into a container:
|
|
|
|
```bash
|
|
java -jar udyamlinc-backend.war # embeds Tomcat 11 — recommended
|
|
# or drop it into a servlet container — see §2c
|
|
```
|
|
|
|
### Pointing the UI at the API without rebuilding
|
|
|
|
The bundle reads its API base from **`config.js`**, served unbundled from the static
|
|
root. Deploy the backend first, then edit one line on the server — no rebuild, no
|
|
redeploy of the UI:
|
|
|
|
```js
|
|
// config.js, at the web root next to index.html
|
|
window.__UDYAM_CONFIG__ = {
|
|
apiBaseUrl: '', // empty = same-origin /api/v1/udyam (the default)
|
|
}
|
|
```
|
|
|
|
Set it only when the API is on another host:
|
|
|
|
```js
|
|
apiBaseUrl: 'https://udyamlinc-api.lincpay.tech/api/v1/udyam'
|
|
```
|
|
|
|
Resolution order is `config.js` → the build-time `VITE_UDYAM_API_BASE_URL` →
|
|
`/api/v1/udyam`. An empty string means "unset", so the shipped file needs no edit for a
|
|
same-origin deployment.
|
|
|
|
Two things that bite:
|
|
|
|
- **`config.js` must never be cached.** The nginx and httpd configs below send
|
|
`no-store` for it. Cached, it pins the old URL for everyone who has already loaded
|
|
the app, and nothing on the server side shows it.
|
|
- **A cross-origin value needs `UDYAM_CORS_ALLOWED_ORIGINS`** set to the UI's origin, or
|
|
the browser blocks every call while the API itself looks perfectly healthy.
|
|
|
|
The UI bundle is otherwise built with a **relative** default. 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|config\.js)$">
|
|
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
|
|
|
|
Supported — the build produces a war and `UdyamLincApplication` extends
|
|
`SpringBootServletInitializer`. Two conditions, and both are hard:
|
|
|
|
**1. Tomcat 11 or newer.** Spring Boot 4.0.1 is built on Jakarta EE 11 / Servlet 6.1.
|
|
Tomcat 9 is `javax.*` and cannot load a `jakarta.*` application at all; Tomcat 10 is
|
|
short of the servlet level. Check before deploying:
|
|
|
|
```bash
|
|
$CATALINA_HOME/bin/version.sh # or version.bat
|
|
```
|
|
|
|
**2. Deploy it as `api#v1#udyam.war`.** Under a container `server.servlet.context-path`
|
|
is ignored — the file name decides the path, and Tomcat reads `#` as a separator. So:
|
|
|
|
```bash
|
|
cp udyamlinc-backend.war $CATALINA_HOME/webapps/api#v1#udyam.war
|
|
```
|
|
|
|
serves `/api/v1/udyam`, which is what the UI's relative API base expects. Deployed under
|
|
any other name every endpoint moves and the UI gets 404s that look like a broken build.
|
|
|
|
Environment variables are read the same way; set them in `setenv.sh` or the service
|
|
definition rather than per-shell.
|
|
|
|
**Still, running the war directly is the lower-risk option** — it embeds Tomcat 11, so
|
|
neither condition can bite, and the same file works both ways.
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
## Restoring the demo database — do this rather than seeding fresh
|
|
|
|
Verified on 2026-08-15: a freshly seeded database and the working demo database are
|
|
**not the same demo**. The seeder writes the plan, which is 129 enterprises; the working
|
|
database carries 263, along with three years of real Agmarknet prices that only a
|
|
`DATAGOV_API_KEY` and a long backfill can produce.
|
|
|
|
| | Restored dump | Fresh boot + bootstrap |
|
|
|---|---|---|
|
|
| Enterprises | 263 | 129 |
|
|
| RED | 50 | 14 |
|
|
| Trip-wires firing | **6** | **5** — TW-05 silent |
|
|
| Open alerts | 344 | 70 |
|
|
| Real Agmarknet rows | 8,292 | 0 |
|
|
|
|
TW-05 reads a price anomaly against a three-year seasonal mean. Generated series are
|
|
calibrated to sit near the real data's typical z of 0.44, so they legitimately do not
|
|
breach a 1.5 threshold — the trip-wire is not broken on a fresh database, it simply has
|
|
nothing anomalous to find.
|
|
|
|
```bash
|
|
createdb -U postgres UdyamLinc_DEMO
|
|
pg_restore -U postgres -d UdyamLinc_DEMO --no-owner --no-privileges udyamlinc_demo.dump
|
|
```
|
|
|
|
Then start the jar. The seeder is idempotent and will leave the restored book alone.
|
|
|
|
`--no-owner --no-privileges` matters when the demo server's Postgres role is not
|
|
`postgres`; without them the restore fails on every `ALTER ... OWNER TO` line.
|
|
|
|
Verify:
|
|
|
|
```sql
|
|
select count(*) from enterprise; -- expect 263
|
|
select source, count(*) from market_signal group by source; -- expect an AGMARKNET row
|
|
```
|
|
|
|
The sequence below is the fallback for a server that has no dump to restore.
|
|
|
|
## 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.
|