Most Node.js hosting guides assume you're using DigitalOcean or AWS. I switched to Hetzner CX22 (€4.35/mo, 2 vCPU, 4GB RAM) and it's been running production workloads for months without a single issue.
Here's the exact setup I use — no fluff, just the commands.
Why Hetzner CX22
A 4GB DigitalOcean droplet costs ~$24/month. The Hetzner CX22 with identical specs costs €4.35/month. I ran benchmarks with autocannon — Hetzner is actually ~3.5% faster. So it's cheaper AND faster. The only trade-off is ticket-only support.
The setup (in order)
1. Create a non-root user immediately
# Login as root (first and only time)
ssh root@YOUR_SERVER_IP
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Never run Node.js as root. This takes 30 seconds and saves you from a class of security issues.
2. Firewall on, then forget about it
sudo apt update && sudo apt upgrade -y
sudo apt install ufw -y
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
All ports closed except 22, 80, 443. Hetzner servers are fully open by default — this step is not optional.
3. Node.js via nvm (not apt)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install --lts
apt install nodejs gives you an outdated version. nvm gives you the current LTS without sudo.
4. Swap file before anything else
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
On a 4GB VPS, npm install during a deploy can spike RAM by 30-50%. Without swap, it OOM-kills your running app mid-deploy. I learned this the hard way.
5. PM2 with memory limits
npm install -g pm2
ecosystem.config.js for a 2-core server:
module.exports = {
apps: [{
name: 'my-app',
script: './dist/index.js',
instances: 2, // one per core
exec_mode: 'cluster',
max_memory_restart: '350M',
node_args: '--max-old-space-size=320',
env_production: { NODE_ENV: 'production', PORT: 3000 }
}]
};
The --max-old-space-size flag is critical. Without it, V8 can claim up to 1.5GB on a 4GB machine and crash the server. Two workers × 350MB = 700MB max, leaving ~90MB headroom for the OS.
pm2 start ecosystem.config.js --env production
pm2 save
pm2 startup # run the command it prints
6. Nginx + free SSL
sudo apt install nginx certbot python3-certbot-nginx -y
/etc/nginx/sites-available/my-app:
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
sudo ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com
Certbot handles HTTPS config and sets up auto-renewal. Done.
Total time
From fresh server to HTTPS Node.js app: ~25 minutes the first time, ~10 minutes once you know the commands.
What I'd do differently
-
Set up SSH key auth only (
PasswordAuthentication noin sshd_config) — I skipped this once and had 2,000 failed login attempts in the logs within an hour -
Monitor with
pm2 monitbefore assuming things are fine — one of my workers had a memory leak that only showed up under load
Full step-by-step guide with exact commands at vpsfor.dev →
Top comments (0)