287 lines
11 KiB
Markdown
287 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.
|
||
|
||
Run these once, in order, after the seeder's final log line:
|
||
|
||
```bash
|
||
BASE=http://localhost:8082/api/v1/udyam
|
||
TOKEN=<a supervisor token from POST $BASE/auth/login>
|
||
|
||
# 1. DPD history — 24 monthly snapshots per loan, stamped SEEDED_HISTORY
|
||
curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/loan-history/generate?months=24"
|
||
|
||
# 2. UPI inflows — 26 weeks per enterprise, stamped SIMULATED.
|
||
# Stands in for Account Aggregator data until Sahamati FIU registration exists.
|
||
curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/upi/simulate"
|
||
|
||
# 3. Prices for the commodities with no API anywhere — milk, egg, fodder, sugarcane
|
||
curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/ingestion/simulate/no-feed"
|
||
|
||
# 4. Derive the features, then score against them
|
||
curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/features/compute"
|
||
curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/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.
|
||
- **This sequence does not give you the real mandi price history.** Steps 1–3 are
|
||
synthetic; the three-year Agmarknet backfill is real data and needs `DATAGOV_API_KEY`
|
||
plus a long one-off run, or a dump of a database that already has it. Without it
|
||
`price_z_score` has no seasonal mean and TW-05 stays quiet.
|
||
|
||
## 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.
|