Managed platforms like Heroku or Render are great until your app gets steady traffic and the monthly bill balloons. Moving to your own VPS gives you full control and predictable costs, but you have to handle process management, reverse proxying, DNS, and edge caching yourself.
I migrated one of my production dynamic dashboard apps to this exact Hostinger VPS setup last month. Cold page load times dropped down to 312ms, and the monthly hosting bill stayed completely flat even during unexpected traffic spikes.

1. Provision Ubuntu 22.04 on Hostinger VPS and Lock Down SSH
In your Hostinger hPanel, spin up a VPS instance running Ubuntu 22.04 64-bit. Once it boots, grab the public IPv4 address from the server overview page.
Log in as root via your local terminal and create a dedicated deploy user right away so you aren’t running application processes with root permissions:
ssh root@YOUR_SERVER_IP # Update package lists and existing software
apt update && apt upgrade -y # Create a new non-root user
adduser deploy
usermod -aG sudo deploy # Copy your SSH authorized keys to the new user
rsync --archive --chown=deploy:deploy ~/.ssh /home/deployNext, lock down the server using UFW (Uncomplicated Firewall). Only expose SSH, HTTP, and HTTPS:
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableRun ufw status to verify your rules before you disconnect from your root session. If you want a deeper walkthrough on initial VPS hardening, check out our guide on how to deploy a web app on Hostinger with Namecheap DNS and SSL.
2. Install Node.js LTS, Git, and PM2 Process Manager
Skip the default Ubuntu apt repository for Node—it almost always bundles an ancient version. Pull the current active LTS (v20.x) using the official NodeSource setup script instead:
# Switch to your deploy user
su - deploy # Download and register NodeSource repo for Node.js 20.x LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - # Install Node.js, npm, and build essentials
sudo apt install -y nodejs build-essential gitVerify both Node and npm are installed and linked properly:
node -v
# Output: v20.12.2 npm -v
# Output: 10.5.0Next, install PM2 globally. PM2 process manager keeps your Node app alive in the background, handles unhandled exceptions without dropping connections, and brings the cluster back online automatically after system reboots.
sudo npm install -g pm23. Set Up the Node.js Express Application
I keep production application code and static assets organized under /var/www/:
sudo mkdir -p /var/www/myapp
sudo chown -R deploy:deploy /var/www/myapp
cd /var/www/myappInitialize a quick Express server to test your routing, environment configs, and asset delivery:
{ "name": "myapp", "version": "1.0.0", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "dotenv": "^16.4.5", "express": "^4.19.2", "helmet": "^7.1.0" }
}Add your main server logic inside /var/www/myapp/server.js:
const express = require('express');
const helmet = require('helmet');
const path = require('path');
require('dotenv').config(); const app = express();
const PORT = process.env.PORT || 3000; app.use(helmet());
app.use(express.json()); // Serve static files with explicit max-age headers for CDN caching
app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d', immutable: true
})); // Health check endpoint
app.get('/api/health', (req, res) => { res.status(200).json({ status: 'online', timestamp: new Date().toISOString(), uptime: process.uptime() });
}); app.get('/', (req, res) => { res.send('Node.js App running on Hostinger VPS
');
}); app.listen(PORT, '127.0.0.1', () => { console.log(`Server listening on http://127.0.0.1:${PORT}`);
});Install your dependencies and create a proper PM2 ecosystem configuration file so you aren’t passing raw CLI flags every time you restart:
// ecosystem.config.js
module.exports = { apps: [{ name: 'myapp', script: 'server.js', instances: 'max', exec_mode: 'cluster', env_production: { NODE_ENV: 'production', PORT: 3000 } }]
};Fire up the cluster using PM2 and set up the system startup hooks:
npm install --production
pm2 start ecosystem.config.js --env production
pm2 save
pm2 startup4. Configure Nginx as a Reverse Proxy with WebSocket Support
Never expose Node.js directly on port 80 or 443 in production. Let Nginx handle SSL termination, static files, and header sanitation per the Nginx proxy documentation.
sudo apt install -y nginxCreate a fresh server block at /etc/nginx/sites-available/myapp.conf:
server { listen 80; listen [::]:80; server_name example.com www.example.com; # Static asset cache control location /static/ { alias /var/www/myapp/public/; expires 30d; add_header Cache-Control "public, no-transform"; access_log off; } # Proxy requests to Node.js / PM2 cluster location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; 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; proxy_cache_bypass $http_upgrade; proxy_read_timeout 60s; }
}Symlink the site to sites-enabled, test the syntax, and reload Nginx:
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl restart nginx5. Map Namecheap DNS A Records and Custom Subdomains
Jump over to Namecheap and point your domain records to your Hostinger VPS public IP. If you’re deciding between naked domain setups, check our breakdown on pointing root domains in Namecheap DNS using A records vs CNAME.
- Open your Domain List and click Manage next to your target domain.
- Go to the Advanced DNS tab.
- Delete any default parking records and add these two entries:
Type: A Record
Host: @
Value: YOUR_HOSTINGER_VPS_IP
TTL: Automatic (or 1 min for testing) Type: CNAME Record
Host: www
Value: example.com.
TTL: AutomaticVerify that your DNS changes have propagated before requesting certificates:
dig +short example.com @8.8.8.8
# Output should return YOUR_HOSTINGER_VPS_IP6. Provision Let’s Encrypt SSL with Certbot
Once DNS resolves to your VPS IP, use Certbot and the Nginx plugin to issue free TLS certificates and set up automatic renewals:
sudo apt install -y certbot python3-certbot-nginx # Request SSL and let Certbot adjust your Nginx config
sudo certbot --nginx -d example.com -d www.example.comCertbot writes the SSL config directly into your myapp.conf file, enforces HTTPS redirects, and enables HTTP/2. Run a dry run to make sure auto-renewal won’t fail down the road:
sudo certbot renew --dry-run7. Configure CDN Caching and Static Asset Edge Headers
Adding an edge CDN in front of your VPS offloads static file requests and shields your Node process from traffic spikes. For step-by-step CDN integration details, see our guide on how to connect a Namecheap domain to Hostinger through StackPath CDN.
To ensure edge nodes cache static JS, CSS, and images properly without messing up API headers or auth tokens, make sure you check our guide on CORS and Cache-Control headers for CDN static assets.
Drop this static location block into your Nginx config:
location ~* .(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf)$ { root /var/www/myapp/public; expires 365d; add_header Cache-Control "public, max-age=31536000, immutable"; add_header Access-Control-Allow-Origin "*"; add_header X-Content-Type-Options "nosniff"; access_log off;
}Apply the changes with sudo nginx -s reload.
8. The Gotcha: 502 Bad Gateway and PM2 Startup Persistence
The first time I rebooted my VPS after setting this up, Nginx threw an immediate 502 Bad Gateway. Nginx was running fine, but Node hadn’t started back up.
Here’s what caught me: running pm2 startup doesn’t enable the systemd service on its own. It generates a custom shell command that you have to copy, paste, and run as sudo. If you miss that output, PM2 won’t hook into systemd.
# What I ran to fix the systemd hook:
pm2 startup systemd -u deploy --hp /home/deploy # Run the exact command PM2 outputs in response, then:
pm2 saveTest your server reboot right now to confirm everything comes back up automatically:
sudo reboot # After 30 seconds, SSH back in and verify:
pm2 statusFrequently Asked Questions
Can I deploy multiple Node.js apps on a single Hostinger VPS?
Yes. Give each Node app its own internal port (like 3000, 3001, 3002) in its ecosystem.config.js file. Then create separate Nginx configuration files with matching server_name directives pointing to those internal ports.
How do I update my application code in production without downtime?
Pull your latest commits into /var/www/myapp, install any updated dependencies, and run pm2 reload myapp. The reload command gracefully cycles workers one by one rather than stopping the entire process at once.
Why does my CDN show TCP_MISS on static asset requests?
If your Express app or Nginx config sends Set-Cookie or Cache-Control: private headers on static files, edge nodes will refuse to cache them. Make sure your static location block explicitly overrides upstream headers with Cache-Control: public.
Do I need to leave port 3000 open in the VPS firewall?
No. Keep port 3000 closed in UFW. Nginx proxies incoming requests locally over 127.0.0.1:3000, so there’s no reason to expose your application port directly to the internet.
Next Steps
Now that your Node.js application is running behind Nginx with automatic SSL, focus on securing your origin server against sudden traffic surges. Learn how to protect your VPS backend in our guide on how to configure an origin shield to prevent CDN origin overload.

