name: lovable-cloudflare-to-vps
description: Deploy a Lovable/TanStack Start (Cloudflare Workers build) app to a VPS using PM2 + Nginx
Deploy Lovable / TanStack Start (Cloudflare) App to VPS
Context
When deploying Lovable-generated apps to a VPS (not Cloudflare), the build output may be for Cloudflare Workers (SSR/router format), not standard Node.js. This requires a different approach than a typical Node.js deployment.
Stack Identified
- - Lovable/TanStack Start + Cloudflare Workers build output
- - Frontend:
dist/client/assets/(JS chunks, CSS) - - Server:
dist/server/assets/worker-entry-*.js(Cloudflare Worker format — not usable on VPS Node.js) - - Supabase: Cloud (not self-hosted)
- - VPS Ubuntu with PM2, Nginx, Node.js 20 via nvm, Bun installed
Approach
1. Extract the zip
`bash
python3 -c "import zipfile; z=zipfile.ZipFile('file.zip'); z.extractall('/var/www/appdir')"
`
2. Build (if needed)
`bash
source /root/.nvm/nvm.sh && nvm use 20
bun install --prefer-offline
bun run build
`
3. Create a static server for the client build
The dist/client/ folder contains the SPA assets. Create serve.cjs in the app root:
`javascript
const http = require('http');
const fs = require('fs');
const path = require('path');
const DIST = __dirname + '/dist/client';
const PORT = process.env.PORT || 3010;
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
};
const server = http.createServer((req, res) => {
let url = req.url.split('?')[0];
let filePath = path.join(DIST, url === '/' ? 'index.html' : url);
if (!fs.existsSync(filePath)) filePath = path.join(DIST, 'index.html');
const ext = path.extname(filePath);
const ct = mimeTypes[ext] || 'application/octet-stream';
try {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': ct });
res.end(content);
} catch {
res.writeHead(404); res.end('Not found');
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(App running on http://127.0.0.1:${PORT});
});
`
4. Create index.html in dist/client/
If missing, create it manually:
`html
`
Replace HASH with actual file hashes from dist/client/assets/.
5. Start with PM2
`bash
npm install -g pm2
pm2 delete appname 2>/dev/null
PORT=3010 pm2 start serve.cjs --name appname
pm2 save
`
6. Nginx config (proxy to PM2)
`nginx
server {
listen 80;
server_name subdomain.domain.com;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /assets/ {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
`
7. SSL with Certbot
`bash
certbot --nginx -d subdomain.domain.com --non-interactive --agree-tos --email email@domain.com --redirect
`
Key Discovery
TanStack Start Cloudflare builds do NOT generate a standalone Node.js server. The dist/server/ contains Cloudflare Worker code, not a Node adapter. Always serve dist/client/ as a static SPA with a custom Node.js server.