How we shipped a production Node.js + React + PostgreSQL app without a VPS, and every painful lesson along the way.
The Situation
We built a full Learning Management System for G3HUB — a React frontend, a Node.js/Express API, and a PostgreSQL database. The only infrastructure we had was a cPanel shared hosting account (Truehost Nigeria) that was already running a WordPress site.
Most deployment tutorials assume you have a VPS or cloud VM. We didn't. We had shared hosting, a deadline, and a problem to solve.
This is the story of how we deployed it anyway — every wall we hit, every workaround we found, and what we'd do differently next time.
The Stack
| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite 7 + TailwindCSS |
| Backend | Node.js 20 + Express 5 |
| Database | PostgreSQL 15 (Supabase) |
| ORM | Drizzle ORM |
| Build Tool | esbuild (monorepo via pnpm workspaces) |
| Frontend Host | cPanel shared hosting (Truehost) |
| API Host | Render (free tier) |
| Database Host | Supabase (free tier) |
The codebase is a pnpm monorepo with separate packages for the frontend, API server, database schema, and generated API clients. Clean architecture — but it made deployment significantly more complicated than a single-repo project.
The Architecture We Ended Up With
Users (Browser)
│ HTTPS
▼
learn.g3hub.com.ng (cPanel/LiteSpeed)
├── React Frontend (static files in public_html/learn/)
└── proxy.php (PHP cURL proxy for /api/* requests)
│ HTTPS
▼
g3hub-learnflow-api.onrender.com (Render free tier)
└── Node.js Express API (6.4MB esbuild bundle)
│ PostgreSQL port 5432
▼
Supabase (aws-1-us-west-2, Session Pooler)
└── PostgreSQL 15 (30+ tables, Drizzle schema)
The key insight: we couldn't run Node.js and PostgreSQL on the same server, so we split them across three free/existing services. The frontend stays on our existing hosting, the API runs on Render, and the database lives on Supabase.
Step 1 — Building the Frontend
The Vite config required two environment variables at build time — PORT and BASE_PATH. Simple enough, except we were building on Windows with Git Bash, and the first build produced an index.html with paths pointing to /Program Files/Git/assets/... instead of /assets/....
Lesson learned: On Windows, always build from PowerShell, not Git Bash. Environment variable resolution works differently.
# PowerShell — this works correctly
cd artifacts/lms
$env:PORT = "3000"
$env:BASE_PATH = "/"
$env:NODE_ENV = "production"
pnpm exec vite build --config vite.config.ts
The build output goes to dist/public/ — these are the static files you deploy to your web server.
Step 2 — Building the API Server
The API server uses esbuild with a custom build.mjs script. This bundles everything — including all workspace dependencies (@workspace/db, @workspace/api-zod) — into a single dist/index.mjs file (6.4MB).
cd artifacts/api-server
pnpm exec node build.mjs
This is important: because esbuild bundles workspace packages at compile time, the resulting dist/index.mjs is completely self-contained and doesn't need the monorepo to run. The only true external runtime dependency is pdfkit (for certificate generation), which wasn't bundled.
Step 3 — Deploying the Frontend to cPanel
This part was straightforward:
- Created a subdomain
learn.g3hub.com.ngin cPanel pointing topublic_html/learn/ - Uploaded and extracted the
dist/public/contents - Added an
.htaccessfile for SPA routing
The .htaccess needed MIME type declarations for JavaScript modules, otherwise Chrome rejected the .js files with a strict MIME type error:
<IfModule mod_mime.c>
AddType application/javascript .js .mjs
AddType text/css .css
</IfModule>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.html [QSA,L]
Wall #1 — Shared Hosting Blocks Outbound PostgreSQL
Our first plan was to run the Node.js API on cPanel using its built-in Node.js App feature (which Truehost supports) and connect it to Supabase.
We set up the Node.js app, uploaded the compiled dist/index.mjs, configured all environment variables, started it — and it immediately crashed:
AggregateError [ECONNREFUSED]: connection refused
We tested the connection directly from the server terminal:
timeout 5 bash -c "echo > /dev/tcp/aws-1-us-west-2.pooler.supabase.com/5432" \
&& echo "Connected" || echo "Failed"
# → Failed
Both port 5432 and 6543 were blocked. We opened a support ticket with Truehost and their response was clear:
"If your application requires direct connectivity to an external PostgreSQL database, we recommend migrating to a VPS or dedicated server environment."
Shared hosting blocks outbound PostgreSQL. Full stop.
Solution: Move the API to Render (free tier). Render has no such restrictions.
Wall #2 — pnpm Workspace Dependencies Don't Exist Outside the Monorepo
When we pushed the api-server folder to GitHub and tried to deploy on Render, the build failed immediately:
error: workspace:* — Not found
The package.json contained references like "@workspace/db": "workspace:*" — dependencies that only exist within our pnpm monorepo. Render's npm/yarn install has no idea what those are.
The fix: since our esbuild bundle already includes everything, we replaced the package.json with a minimal version containing only the true external dependencies:
{
"name": "g3hub-learnflow-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --enable-source-maps ./dist/index.mjs"
},
"dependencies": {
"pdfkit": "^0.18.0"
}
}
That's it. Everything else is already bundled.
Wall #3 — Apache mod_proxy Won't Proxy to External HTTPS
Once the API was running on Render, we needed the frontend (on cPanel) to forward /api/* requests to the Render URL. The obvious solution was Apache's mod_proxy:
RewriteRule ^api/(.*)$ https://g3hub-learnflow-api.onrender.com/api/$1 [P,L]
This didn't work either. The requests timed out every time, even though curl from the same server could reach Render just fine:
curl -s https://g3hub-learnflow-api.onrender.com/api/healthz
# → {"status":"ok"}
Apache's mod_proxy on shared hosting can reach the destination but the module configuration itself seems restricted. We never got a clear answer from the hosting provider.
Solution: Use a PHP cURL proxy instead. PHP's curl functions work perfectly on shared hosting, even for outbound HTTPS to external servers:
<?php
$path = isset($_GET['path']) ? $_GET['path'] : '';
$target = 'https://g3hub-learnflow-api.onrender.com/api/' . $path;
$method = $_SERVER['REQUEST_METHOD'];
$headers = [];
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
$headers[] = 'Authorization: ' . $_SERVER['HTTP_AUTHORIZATION'];
}
if (isset($_SERVER['CONTENT_TYPE'])) {
$headers[] = 'Content-Type: ' . $_SERVER['CONTENT_TYPE'];
}
$ch = curl_init($target);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($httpCode);
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
echo $response;
Then update .htaccess to route API calls through this script:
RewriteCond %{REQUEST_URI} ^/api/(.*)$
RewriteRule ^api/(.*)$ /proxy.php?path=$1 [QSA,L]
The frontend calls /api/auth/login → Apache rewrites to /proxy.php?path=auth/login → PHP forwards to Render → response comes back. Works perfectly.
Wall #4 — Supabase SSL Certificate Chain Error
When the API first connected to Supabase from Render, every database query failed with:
Error: self-signed certificate in certificate chain
This is a known quirk with Supabase's connection pooler. The SSL certificate chain isn't fully trusted by Node.js's default certificate store.
The workaround (acceptable for this use case since the connection is still encrypted):
NODE_TLS_REJECT_UNAUTHORIZED=0
Add this as an environment variable in Render. Node.js will still use SSL/TLS for the connection — it just won't verify the certificate chain.
Wall #5 — Supabase Session Pooler Username Format
The DATABASE_URL for Supabase's Session Pooler uses a special username format:
postgresql://postgres.<PROJECT_REF>:<PASSWORD>@aws-1-us-west-2.pooler.supabase.com:5432/postgres
Note the postgres.<PROJECT_REF> username — this is different from the direct connection which uses just postgres. Using the wrong format gives:
error: (ENOTFOUND) tenant/user postgres.hnzrl... not found
Also: if your password contains special characters like @ or !, URL-encode them. @ becomes %40, ! becomes %21. Or better — use a password without special characters to avoid encoding issues entirely.
Wall #6 — Drizzle Kit Schema Push on Windows
Running drizzle-kit push from Windows to push the schema to Supabase failed with:
Error: No schema files found for path config ['C:\Users\...\lib\db\src\schema\index.ts']
The original drizzle.config.ts uses __dirname which doesn't work in ESM modules on Windows. The fix was to create a temporary config file with hardcoded relative paths:
// drizzle.temp.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
});
Then:
DATABASE_URL="<your-url>" pnpm exec drizzle-kit push --config ./drizzle.temp.config.ts
Wall #7 — Service Worker Aggressive Caching
After redeploying the frontend with fixes, the browser kept showing the old broken version. The PWA service worker (sw.js) was intercepting all requests and returning cached responses — including returning {"offline": true} for API calls.
Fix: In Chrome DevTools → Application → Service Workers:
- Check "Bypass for network"
- Click Unregister
- Go to Storage → Clear site data
After every significant frontend redeployment, users may need to do this if the service worker cache isn't properly invalidated.
The Final Architecture — Why It Works
The clever part of this setup is the PHP proxy. Because the frontend and the PHP proxy live on the same domain (learn.g3hub.com.ng), there are no CORS issues. The browser thinks it's talking to the same server. The PHP script silently forwards requests to Render in the background.
This pattern — using a PHP proxy on shared hosting to forward to an external API — is a legitimate production technique, not just a hack. It works because:
- PHP's cURL is not subject to the same firewall restrictions as Apache mod_proxy
- The proxy runs server-side, so no CORS headers are needed between frontend and API
- The connection from cPanel to Render is standard HTTPS on port 443, which shared hosting almost never blocks
Cost Summary
| Service | Plan | Cost |
|---|---|---|
| Truehost cPanel | Existing | $0 (already paid) |
| Render | Free | $0 |
| Supabase | Free | $0 |
| Total | $0/month |
The only catch: Render's free tier sleeps after 15 minutes of inactivity, causing a 50-second cold start on the first request. Fix this for free with UptimeRobot — set it to ping /api/healthz every 5 minutes.
What We'd Do Differently
If we could start over:
Use a VPS from day one. A $5/month DigitalOcean or Hetzner VPS eliminates every single wall in this article. No firewall restrictions, no proxy hacks, full control. For a production LMS, it's worth it.
Separate the monorepo deployment config from the code. Having
@workspace/*dependencies in the API'spackage.jsoncaused unnecessary pain. Build-time monorepo dependencies should be bundled; the deployment artifact should be self-contained.Use a password without special characters for the database. URL-encoding
@as%40in connection strings caused hours of debugging.G3hubDB2026Secureworks better thanG3hub@DB2026!.Test the proxy architecture early. We spent days trying to make cPanel's Node.js App work before discovering the PHP proxy pattern. Testing the outbound connectivity from the server early would have saved time.
Lessons for Other Engineers
- Shared hosting is not a dead end — it just requires creative routing
- PHP is still useful — as a proxy layer, it solves problems that Apache can't
- esbuild bundles are powerful — a self-contained 6.4MB bundle is easier to deploy than a full monorepo
- Free tiers are production-viable — with the right architecture, $0/month infrastructure can run a real application
- Always test outbound connectivity first — before building your deployment pipeline, verify that the server can reach your database and any external services
Resources
- Render Free Tier
- Supabase Free Tier
- Drizzle ORM
- UptimeRobot — free uptime monitoring to keep Render awake
- pnpm Workspaces
If you found this useful or hit similar walls deploying to shared hosting, leave a comment. Every painful deployment is a future blog post.
Tags: node react postgresql devops webdev
Top comments (0)