How to Deploy Node.js App on a Cheap Server

by David Park
How to Deploy Node.js App on a Cheap Server

What You'll End Up With

By the end of this guide you'll have a Node.js application running in production on a $4.51/month Hetzner CAX11 VPS (2 vCPU ARM64, 4 GB RAM), managed by PM2, reverse-proxied through Nginx, and secured with a Let's Encrypt TLS certificate. The app survives reboots, restarts on crash, and serves HTTPS traffic on port 443. This is the exact stack I run for two of my SaaS products today.

Prerequisites

  • A Hetzner account (or any Ubuntu 24.04 VPS — DigitalOcean, Vultr, or similar)
  • A domain name with an A record pointing to your server IP
  • SSH access as root or a sudo user
  • A Node.js app with a package.json and a start script (e.g., npm start runs node server.js on port 3000)
  • Basic comfort at a Linux terminal

Step 1 — Provision and Harden the Server

Create a Hetzner CAX11 instance running Ubuntu 24.04. Once it's up, SSH in as root and create a non-root deploy user.

1.1 — Create the deploy user and grant sudo.

adduser deploy
usermod -aG sudo deploy

1.2 — Copy your SSH key to the new user.

rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

1.3 — Disable root SSH login and password auth.

Edit /etc/ssh/sshd_config:

sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh

1.4 — Open only the ports you need.

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

Expected output:

Firewall is active and enabled on system startup

Step 2 — Install Node.js 22 via NodeSource

Do not use the Ubuntu default nodejs package — it lags several major versions behind. Install directly from NodeSource.

2.1 — Add the NodeSource repository for Node.js 22.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -

2.2 — Install Node.js and npm.

sudo apt-get install -y nodejs

2.3 — Confirm the installed versions.

node --version
npm --version

Expected output:

v22.x.x
10.x.x

Step 3 — Deploy Your Application Code

Switch to the deploy user for all remaining steps.

su - deploy

3.1 — Clone your repository (or copy files with rsync).

git clone https://github.com/youruser/yourapp.git /home/deploy/yourapp

If you're not using Git, push files from your local machine:

rsync -avz ./yourapp/ deploy@YOUR_SERVER_IP:/home/deploy/yourapp/

3.2 — Install production dependencies only.

cd /home/deploy/yourapp
npm ci --omit=dev

npm ci installs exactly what's in package-lock.json — no surprises in production.

3.3 — Create a .env file for environment variables.

nano /home/deploy/yourapp/.env

Add your variables, one per line:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/mydb

Restrict permissions so only the deploy user can read it:

chmod 600 /home/deploy/yourapp/.env

Step 4 — Run the App with PM2

PM2 keeps your app alive, restarts it on crash, and integrates with systemd so it survives reboots.

4.1 — Install PM2 globally.

sudo npm install -g pm2@latest

4.2 — Start your app with PM2 and an explicit name.

pm2 start /home/deploy/yourapp/server.js --name yourapp --env production

If your app uses a build step or a custom start command, use the --interpreter flag or create an ecosystem.config.js (see Step 4.4).

4.3 — Save the PM2 process list and enable startup on boot.

pm2 save
pm2 startup systemd -u deploy --hp /home/deploy

PM2 prints a sudo command — copy and run it exactly as shown. It looks like:

sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deploy

4.4 — (Optional) Use an ecosystem file for cleaner config.

Create /home/deploy/yourapp/ecosystem.config.js:

module.exports = {
  apps: [
    {
      name: 'yourapp',
      script: './server.js',
      instances: 1,
      exec_mode: 'fork',
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};

Then start with:

pm2 start ecosystem.config.js --env production
pm2 save

Step 5 — Configure Nginx as a Reverse Proxy

Nginx sits in front of Node.js, handles TLS termination, and forwards traffic to port 3000.

5.1 — Install Nginx.

sudo apt-get install -y nginx

5.2 — Create a server block for your domain.

sudo nano /etc/nginx/sites-available/yourapp

Paste the following, replacing yourdomain.com with your actual domain:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    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;
    }
}

5.3 — Enable the site and remove the default block.

sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Expected output from nginx -t:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Step 6 — Issue a TLS Certificate with Certbot

6.1 — Install Certbot and the Nginx plugin.

sudo apt-get install -y certbot python3-certbot-nginx

6.2 — Obtain and install the certificate.

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot rewrites your Nginx config to add listen 443 ssl and redirect HTTP to HTTPS automatically.

6.3 — Verify automatic renewal.

sudo systemctl status certbot.timer

Expected output includes active (waiting) — Certbot renews certificates automatically via a systemd timer.


Verify It Works

Run each check in order. All should pass before you consider the deployment complete.

Check PM2 process status.

pm2 status

Expected output:

┌─────┬──────────┬─────────────┬─────────┬─────────┬──────────┐
│ id  │ name     │ namespace   │ version │ mode    │ status   │
├─────┼──────────┼─────────────┼─────────┼─────────┼──────────┤
│ 0   │ yourapp  │ default     │ 1.0.0   │ fork    │ online   │
└─────┴──────────┴─────────────┴─────────┴─────────┴──────────┘

Check Nginx is running.

sudo systemctl status nginx

Look for active (running).

Check HTTPS response from your domain.

curl -I https://yourdomain.com

Expected output includes:

HTTP/2 200

Simulate a reboot.

sudo reboot

SSH back in after 30 seconds and run pm2 status again. The process should be online without any manual intervention.


Troubleshooting

PM2 shows errored status. Run pm2 logs yourapp --lines 50 to see the last 50 log lines. The most common causes are a missing .env file, wrong PORT, or a missing node_modules directory. Confirm npm ci completed without errors.

Nginx returns 502 Bad Gateway. Your Node.js app is not listening on port 3000, or it crashed. Run pm2 status and pm2 logs yourapp. Also confirm the port in your .env matches the proxy_pass address in /etc/nginx/sites-available/yourapp.

Certbot fails with Connection refused. Port 80 must be open and Nginx must be running before you request a certificate. Verify with sudo ufw status and curl -I http://yourdomain.com.

App does not start after reboot. You likely skipped the sudo command that PM2 printed during pm2 startup. Run pm2 startup systemd -u deploy --hp /home/deploy again and execute the printed command, then run pm2 save.

npm ci fails with peer dependency errors. Add --legacy-peer-deps flag: npm ci --omit=dev --legacy-peer-deps. This is a last resort — investigate the dependency conflict in development first.

UFW blocks your app. Node.js on port 3000 is accessed only by Nginx on localhost — UFW does not need to allow port 3000 externally. If you're testing Node.js directly, temporarily run curl http://127.0.0.1:3000 from the server itself.


Next Steps

Your Node.js app is now running in production on a cheap server with automatic restarts, HTTPS, and reboot persistence. From here, consider:

  • Zero-downtime deploys: use pm2 reload yourapp instead of pm2 restart — it cycles workers without dropping connections.
  • Log rotation: run pm2 install pm2-logrotate to prevent logs from filling your disk.
  • Monitoring: pm2 monit gives a live CPU/memory dashboard in the terminal.
  • Database: if you need Postgres on the same server, install postgresql-16 from the PGDG apt repository — it runs comfortably alongside Node.js on 4 GB RAM.
  • Automated deploys: add a GitHub Actions workflow that SSH-es into the server, pulls the latest code, runs npm ci --omit=dev, and calls pm2 reload yourapp.

This stack — Hetzner + PM2 + Nginx + Certbot — costs under $5/month and handles several hundred concurrent users without breaking a sweat. Scale vertically first (Hetzner CX22 at $7.49/month doubles the CPU) before reaching for anything more complex.