name: documents-vps-deploy
description: Deploy Documents app (TanStack Start) to VPS β post-build sync and systemd env vars
category: devops
Documents VPS Deploy β Critical Post-Build Sync
Context
Documents app (TanStack Start + Vite) on VPS at documentos.rochasalesseguros.com.br, port 3010. Service: systemctl documentos.
The Problem (HTTP 500 on all routes after build)
After a Lovable/TanStack Start build, the build output is in dist/ but the running app serves from .output/. TWO syncs are needed after every build β missing the server assets sync causes 500 on ALL routes because TanStack Start's SSR manifest references new hash-named files that don't exist in .output/.
Build + Deploy Steps (TanStack Start v1.167.65 target: "node-server")
`bash
cd /var/www/documentos/deploy-nodejs/deploy-vps-nodejs/app
1. Build (nvm Node 20 required)
source ~/.nvm/nvm.sh && nvm use 20 && npm run build
Build outputs to:
dist/client/ β static assets (JS, CSS, etc.)
dist/server/server.js β SSR handler (NOT a standalone HTTP server)
.output/server/index.mjs β thin wrapper: re-exports from dist/server/server.js
2. NO need to sync to .output/ anymore β server.js points directly to dist/
(see server.js patch below β this replaced the old .output/ sync approach)
3. Restart
sudo systemctl restart documentos
`
CRITICAL: server.js must be updated after every rebuild
The server.js in the app root is the custom HTTP server wrapper β it creates the HTTP server, serves static files from dist/client, and forwards SSR requests to the handler. It does NOT auto-update after npm run build. After every rebuild you MUST verify (and patch if needed):
`bash
Check what server.js currently imports
grep -n "output\|dist/server" /var/www/documentos/deploy-nodejs/deploy-vps-nodejs/app/server.js
`
Current working config (after 2025-05-12 fix):
- -
import handler from './dist/server/server.js'(NOT.output/server/index.mjs) - -
const CLIENT_DIR = './dist/client'(NOT.output/client) - - Check browser console: if you see
[Supabase] Missing Supabase environment variable(s): SUPABASE_PUBLISHABLE_KEYβ addSUPABASE_PUBLISHABLE_KEYto systemd service (see above) - - Check
ps aux | grep nodeand verify the process has the env vars:cat /proc//environ | tr '\0' '\n' | grep SUPABASE - - Always
systemctl daemon-reload && systemctl restart documentosafter editing the service file - - Old errors in
/var/log/documentos.err.logmay persist β check if log is still growing:tail /var/log/documentos.err.log - - Root cause: The
server.jscustom Node.js HTTP server converts requests to Fetch APIRequestobjects. On Node.js 20+, when passing a raw Node.js request stream as body,duplex: 'half'MUST be set. If body isundefined, duplex is not needed β but the crash occurred because passingreq(Node stream) as body without duplex causes undici to throw. - - Correct pattern (always set
duplex: 'half'when body is present): - - Restart after fixing:
systemctl restart documentos - - How to diagnose: Check
tail /var/log/documentos.err.logβ if you see the undici error withserver.js:72, this is it. The server will be in a crash loop (systemd restarts it every 5s). Symptoms: static assets (JS/CSS) serve 200 OK, but SSR routes return nothing or timeout. - - File location:
/var/www/documentos/deploy-nodejs/deploy-vps-nodejs/app/server.js - - TanStack Start SSR uses streaming markers β
means the server rendered nothing (empty fragment) - -
/authroute often renders empty on SSR (auth check is client-side) β this is normal - - If
/or other routes also show empty SSR markers β the React app is crashing during SSR - - Check:
curl -s https://documentos.rochasalesseguros.com.br/and look for actual HTML content (forms, buttons, text) - - "This browser or app may not be secure" β Google blocks browserless OAuth
- - Complex popup/redirect flow with React hydration issues (#418)
- - postMessage race conditions between popup and main window
Why .output/ was wrong: TanStack Start v1.167.65 target: "node-server" outputs the SSR handler to dist/server/server.js. The old .output/server/index.mjs was a thin wrapper re-exporting from dist/server/server.js β the wrapper itself is meaningless. The build's hash-named assets (JS chunks) live in dist/client/assets/.
CRITICAL: systemd ExecStart must point to server.js, NOT dist/server/server.js
The dist/server/server.js is only a handler (TanStack Start entry point for Vite SSR handler). It does NOT create an HTTP server β when run directly it exits immediately with code 0.
`bash
CORRECT (points to the HTTP server wrapper):
ExecStart=/usr/bin/node /var/www/documentos/deploy-nodejs/deploy-vps-nodejs/app/server.js --port 3010
WRONG (handler exits immediately, no HTTP server):
ExecStart=/usr/bin/node /var/www/documentos/deploy-nodejs/deploy-vps-nodejs/app/dist/server/server.js
`
After editing /etc/systemd/system/documentos.service:
`bash
sudo systemctl daemon-reload && sudo systemctl restart documentos
`
Systemd Service Env Vars (CRITICAL β must be correct)
IMPORTANT β use EnvironmentFile, NOT individual Environment= lines
The service file at /etc/systemd/system/documentos.service MUST use EnvironmentFile to load vars from .env:
`
EnvironmentFile=/var/www/documentos/deploy-vps-nodejs/app/.env
`
NOT individual Environment= lines. Using Environment="VAR=value" lines does not work β the JWT_SECRET and other vars from .env will not be loaded, causing 401 "SessΓ£o invΓ‘lida" on authenticated API calls.
After editing, always run:
`bash
systemctl daemon-reload && systemctl reset-failed documentos && systemctl restart documentos
`
Systemd Service Env Vars (CRITICAL β must be correct) β OLD (deprecated)
The service file at /etc/systemd/system/documentos.service MUST have these exact values (DO NOT use ComercialRS project lzoiuxulhnvtmadoymrb):
`
Environment="SUPABASE_URL=https://nzaxsorgsecnmqwufawn.supabase.co"
Environment="SUPABASE_PUBLISHABLE_KEY=eyJ_REDACTED"
Environment="VITE_SUPABASE_PROJECT_ID=nzaxsorgsecnmqwufawn"
Environment="VITE_SUPABASE_PUBLISHABLE_KEY=eyJ_REDACTED"
Environment="VITE_SUPABASE_URL=https://nzaxsorgsecnmqwufawn.supabase.co"
`
SUPABASE_PUBLISHABLE_KEY (without VITE_ prefix) is CRITICAL β the SSR renderer reads this at runtime from process.env. If missing, the browser console shows [Supabase] Missing Supabase environment variable(s): SUPABASE_PUBLISHABLE_KEY and the page renders as a blank/black screen (no React components mount). The VITE_* vars are stripped at build time and not available to server-side code.
Always run systemctl daemon-reload && systemctl restart documentos after editing the service file.
If env vars are wrong β blank/black screen in browser (no JS errors in console, but no content renders), or 500 on all routes.
Diagnosing 401 "SessΓ£o invΓ‘lida" on upload
When uploads return 401 even with valid session:
1. Add debug logging to the upload handler (temporarily):
`bash
# In src/routes/api/public/upload-document.ts, catch block:
console.error("[DEBUG] JWT verify failed:", jwtErr.name, jwtErr.message, "| token len:", token.length, "| token start:", token.slice(0,30));
`
2. Rebuild: npm run build && systemctl restart documentos
3. Check logs: tail /tmp/doc.log (systemd StandardOutput=append:/tmp/doc.log)
4. The debug output reveals:
- TokenExpiredError β token expired, user needs to re-login
- JsonWebTokenError: jwt signature invalid β JWT_SECRET mismatch (env var not loaded in systemd, or .env changed)
- JsonWebTokenError: invalid token β malformed token from browser
Most common cause of 401 after env change: systemd service uses Environment="VAR=value" individual lines instead of EnvironmentFile=/path/to/.env. The .env file has the actual values, but systemd ignores them. Fix:
`
EnvironmentFile=/var/www/documentos/deploy-vps-nodejs/app/.env
`
Then: systemctl daemon-reload && systemctl reset-failed documentos && systemctl restart documentos
Troubleshooting
Blank/black screen after env var change
Server crashing with TypeError: RequestInit: duplex option is required when sending a body
`js
const hasBody = ['POST', 'PUT', 'PATCH'].includes(req.method);
const fetchReq = new Request(http://${req.headers.host}${req.url}, {
method: req.method,
headers,
body: hasBody ? req : undefined,
duplex: 'half', // required when body is present (Node.js stream)
});
`
Finding which server.js is actually running (PID discovery)
When debugging, always verify the actual running process and its working directory:
`bash
Find the PID and its working directory
systemctl show documentos --property=MainPID --no-pager
readlink /proc/
Check the actual command and its arguments
cat /proc/
Verify env vars are actually set in the running process
cat /proc/
`
This prevents wasting time on the wrong server.js file.
SSR renders markers (no content)
Gmail SMTP / App Password Integration (Recommended over OAuth)
Why not OAuth?
Google OAuth fails in headless/server environments:
Use Gmail App Password via SMTP instead β simpler, more reliable, no popups.
Setup Steps
1. Install nodemailer:
`bash
cd /var/www/documentos/deploy-vps-nodejs/app
source ~/.nvm/nvm.sh && nvm use 20 && npm install nodemailer
`
2. Generate App Password:
- Go to https://myaccount.google.com/apppasswords
- Login with the sender email
- Create app with name "DocCorretor" (or similar)
- Copy the 16-character password (format xxxx xxxx xxxx xxxx)
3. Add to .env (generate 32-byte hex key for encryption):
`bash
# Generate encryption key
openssl rand -hex 32
`
`
GMAIL_APP_PASSWORD_KEY=<32-byte-hex-key>
`
4. Add to systemd service (/etc/systemd/system/documentos.service):
`
EnvironmentFile=/var/www/documentos/deploy-vps-nodejs/app/.env
`
Then: systemctl daemon-reload && systemctl restart documentos
5. Routes to create (under src/routes/api/gmail/):
- app-password.ts β GET/POST/DELETE for SMTP config, stores encrypted password in public.app_settings
- send.ts β reads encrypted password, creates nodemailer SMTP transporter, sends email with attachments
- smtp-config.ts β GET/POST/DELETE for SMTP settings check
6. Storage in DB (public.app_settings):
- smtp.app_password β AES-encrypted (via GMAIL_APP_PASSWORD_KEY)
- smtp.sender_email β set to Supabase session user email at save time
Key pattern: sender email = login email
The sender email is automatically the Supabase authenticated user's email. No separate sender selection needed.
Verify after deploy
`bash
Test public URL
curl -s -o /dev/null -w "%{http_code}" https://documentos.rochasalesseguros.com.br/
Test assets are served
curl -s -o /dev/null -w "%{http_code}" https://documentos.rochasalesseguros.com.br/assets/index-BDfKYpOe.js
`
Build command
`bash
source ~/.nvm/nvm.sh && nvm use 20
npm run build
`