How a Hardcoded IP Silently Killed External Access — A Vite + Nginx Cautionary Tale
A hardcoded private IP address buried in frontend code completely broke external access. Here's the full investigation, fix, and deployment journey.
Background
We needed to make our internal business system (Vite + React frontend + Express backend) accessible from external networks via erp.example.com. Everything worked perfectly within the LAN, but external access simply wouldn't function.
The Configuration Problem We Discovered
Investigation revealed this setup:
-
erp.example.comnginx configuration:-
/api/→127.0.0.1:3210(backend) ✅ -
/→127.0.0.1:3101(frontend) ❌ — Nothing was running on this port
-
- The frontend was actually served by a vite dev server (port 3101) on a different machine — accessible only within LAN
Essentially, the development vite dev server was being used as the "production" service.
The Real Killer: Hardcoded IPs
The bigger problem was private IP addresses hardcoded throughout the frontend source code:
// erpApi.ts
const BASE_URL = 'http://192.168.x.x:8520/api';
// Various page components
fetch('http://192.168.x.x:8520/api/sales/report/ledger', ...)
fetch('http://192.168.x.x:3210/api/v1/arrivals/slip?ids=1', ...)
Browsers within the LAN could reach 192.168.x.x directly, so it worked. External clients obviously couldn't. Even if we built with vite and served via nginx, these IPs would be baked into the compiled JavaScript.
Result: External access led to all API calls failing with ERR_CONNECTION_TIMED_OUT.
The Fix
1. Replace Hardcoded IPs with Relative Paths
// Before
const BASE_URL = 'http://192.168.x.x:8520/api';
// After
const BASE_URL = '/api';
Target files: erpApi.ts, erpCompat.ts, various list pages (SaleList, KaikakeList, NyukinList, etc.). Used grep to find all instances of 192.168.x and eliminated them.
2. Create .env.production
VITE_API_URL=/api/v1
VITE_ERP_URL=/api
Set up Vite environment variables to switch API base URLs between development and production.
3. Update Nginx Configuration
# Before: proxy to vite dev server (not running)
location / {
proxy_pass http://127.0.0.1:3101;
}
# After: serve built static files directly
location / {
root /www/apps/erp-admin;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:3210;
}
Classic SPA configuration with try_files fallback to index.html for all routes.
4. Build & Deploy
cd /mnt/shared/projects/platform/erp-admin
npm run build # generates hashed files in dist/
scp -r dist/* admin@webserver:/www/apps/erp-admin/
ssh admin@webserver "nginx -t && nginx -s reload"
The Vite Dev Proxy Trap
A related gotcha: server.proxy settings in vite.config.ts only apply during development (npm run dev) and have zero impact on npm run build output.
// vite.config.ts — this is dev server config, not build config
server: {
proxy: {
'/api/v1': 'http://192.168.x.x:8520',
}
}
This creates the "works in dev but breaks in build" pattern. Production proxying must be configured on the web server side (nginx).
Lessons Learned
-
Never hardcode private IPs in frontend code. Use relative paths (
/api) or environment variables (import.meta.env.VITE_API_URL). -
Don't use vite dev server for production serving. The correct approach is
npm run build→ nginx static file serving. -
server.proxyis dev-only. Production reverse proxying must be configured in the web server. - "Working in LAN" isn't real validation. Without testing external access, hardcoded IPs will go unnoticed.
These seem obvious in hindsight, but "it works for now" shortcuts accumulate quickly. When you finally try to go public, everything breaks. Start by grepping for 192.168.x in your codebase.
Tags: #vite #nginx #frontend #deployment #spa #erp #hardcoded-ip #devops
Top comments (0)