Files
udyamlinc-api/DEPLOYMENT.md
T

14 KiB

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:

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:

// 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:

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/.

java -jar udyamlinc-backend.jar

Configuration is entirely by environment variable — nothing needs editing inside the jar:

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:

CREATE DATABASE "UdyamLinc_DEMO";

As a systemd service

[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:

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:

a2enmod proxy proxy_http rewrite headers

Virtual host:

<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):

<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:

$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:

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.

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:

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:

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:

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.