How to Deploy a Node.js App to a Linux VPS

Running node server.js on your laptop is not a deployment. A real deployment means your app runs 24/7, restarts automatically after crashes, is accessible at a domain name over HTTPS, and can be updated without manually SSH-ing into the server. This guide covers all of that — from a blank Ubuntu VPS to a production-grade Node.js deployment with automated deploys on git push.

What You Need

  • A VPS running Ubuntu 22.04 LTS — typical entry-level cost is $4–12/month
  • A domain name pointing to the server's IP address
  • Your Node.js app in a GitHub repository
  • A local machine with SSH installed

Recommended VPS providers:

  • DigitalOcean — $200 free credit for new users, best developer docs, $6/mo entry Droplet
  • Hostinger VPS — starts at $4.99/mo, KVM virtualisation, NVMe SSD
  • Cloudways — if you want managed infrastructure (Nginx + PHP + Redis pre-configured, no terminal needed)

Step 1: Initial Server Setup

When you create a new VPS, you typically get root access via a password or SSH key. Before anything else, create a non-root user with sudo privileges — running applications as root is a security risk.

# SSH into the server as root
ssh root@YOUR_SERVER_IP

# Create a deploy user
adduser deploy
usermod -aG sudo deploy

# Set up SSH key authentication for the deploy user
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Now you can SSH as the deploy user: ssh deploy@YOUR_SERVER_IP. Do the rest of this guide as that user.

Configure the Firewall

sudo ufw allow OpenSSH
sudo ufw allow 80    # HTTP
sudo ufw allow 443   # HTTPS
sudo ufw enable
sudo ufw status

This allows SSH, HTTP, and HTTPS — and blocks everything else, including direct access to your Node.js app's port (3000). All traffic will go through Nginx, which we set up later.

Step 2: Install Node.js with nvm

Don't install Node.js from apt — the version is usually outdated. Use nvm (Node Version Manager), which lets you install and switch between multiple Node.js versions:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

# Reload shell profile
source ~/.bashrc

# Install Node.js 20 LTS
nvm install 20
nvm use 20
nvm alias default 20

# Verify
node --version  # v20.x.x
npm --version   # 10.x.x

Step 3: Clone Your Application

cd /var/www
sudo mkdir myapp
sudo chown deploy:deploy myapp

# Clone the repository
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git myapp
cd myapp

# Install production dependencies
npm ci --omit=dev

Set Environment Variables

Do not create a .env file on the server and check it into git. Instead, create it directly on the server and add it to .gitignore so it is never overwritten by git pull:

nano /var/www/myapp/.env
NODE_ENV=production
PORT=3000
DB_HOST=localhost
DB_NAME=myapp_prod
DB_USER=myapp
DB_PASS=a_strong_password_here
APP_SECRET=a_random_64_char_string

Save the file. Make it readable only by the deploy user:

chmod 600 /var/www/myapp/.env

Step 4: Set Up PM2 Process Manager

PM2 is the standard process manager for Node.js in production. It keeps your app running, restarts it if it crashes, provides log management, and can start your app automatically when the server reboots.

npm install -g pm2

# Start your application
pm2 start /var/www/myapp/server.js --name myapp

# Check that it's running
pm2 status

# View logs
pm2 logs myapp --lines 50

Auto-Start on Server Reboot

# Save current process list
pm2 save

# Generate and install the startup script
pm2 startup

# PM2 will print a command — run it exactly as printed, e.g.:
sudo env PATH=$PATH:/home/deploy/.nvm/versions/node/v20.x.x/bin pm2 startup systemd -u deploy --hp /home/deploy

After this, PM2 will restart your app automatically on every server reboot.

PM2 Ecosystem File for Production

For more control, create an ecosystem file instead of passing everything on the command line:

// /var/www/myapp/ecosystem.config.js
module.exports = {
    apps: [{
        name:          'myapp',
        script:        'server.js',
        instances:     'max',     // use all CPU cores
        exec_mode:     'cluster',
        env_production: {
            NODE_ENV: 'production',
            PORT:      3000,
        }
    }]
};
pm2 start ecosystem.config.js --env production
pm2 save

instances: 'max' with exec_mode: 'cluster' runs one Node.js process per CPU core, handling more concurrent requests. PM2 load-balances between them automatically.

Step 5: Nginx as a Reverse Proxy

Nginx sits in front of your Node.js app, handles SSL termination, serves static files efficiently, and forwards API/app requests to the Node.js process on port 3000.

sudo apt install nginx -y
sudo systemctl enable nginx

Create a site configuration:

sudo nano /etc/nginx/sites-available/myapp
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_cache_bypass $http_upgrade;
    }
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t       # test config syntax
sudo systemctl reload nginx

Your app is now accessible at http://yourdomain.com via Nginx. Next: HTTPS.

Step 6: SSL Certificate with Certbot

Let's Encrypt provides free, auto-renewing SSL certificates. Certbot handles the installation and renewal automatically:

sudo apt install certbot python3-certbot-nginx -y

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

Certbot modifies the Nginx config automatically to add SSL, redirect HTTP to HTTPS, and set up certificate paths. It also installs a cron job to auto-renew the certificate before it expires (Let's Encrypt certificates are valid for 90 days).

After running Certbot, your site is live at https://yourdomain.com.

Step 7: Automated Deployment via GitHub Actions

Now that the server is set up, automate future deployments so that merging to main automatically updates the server.

Create a dedicated deploy SSH key

# On your local machine
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy
# Don't add a passphrase — Actions needs it unprotected

cat ~/.ssh/github_deploy.pub
# Copy this output
# On the server — add the public key
echo "PASTE_THE_PUBLIC_KEY_HERE" >> /home/deploy/.ssh/authorized_keys

In GitHub → Settings → Secrets, add:

  • SSH_HOST — your server IP
  • SSH_USERdeploy
  • SSH_PRIVATE_KEY — contents of ~/.ssh/github_deploy (the private key)
  • SSH_PORT22 (or your custom port)

The Deploy Workflow

Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host:     ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key:      ${{ secrets.SSH_PRIVATE_KEY }}
          port:     ${{ secrets.SSH_PORT }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --omit=dev
            pm2 reload myapp --update-env

pm2 reload performs a zero-downtime restart — new processes start and accept connections before the old ones shut down. Your app stays online during the update.

Monitoring and Logs

Essential commands for maintaining the deployment:

# PM2 — real-time process monitoring dashboard
pm2 monit

# PM2 — stream application logs
pm2 logs myapp

# Nginx — access log (every request)
sudo tail -f /var/log/nginx/access.log

# Nginx — error log (only errors)
sudo tail -f /var/log/nginx/error.log

# Check application port is listening
ss -tlnp | grep 3000

# Check Nginx is serving correctly
curl -I https://yourdomain.com

Need a VPS to deploy your application? DigitalOcean offers a $200 credit for new users — enough to run a production server for months.

Get $200 DigitalOcean Credit

Frequently Asked Questions

What is the difference between a VPS and shared hosting?

Shared hosting puts multiple websites on a single server, sharing its CPU, memory, and resources. You get a control panel (cPanel) but limited ability to install custom software, run long-lived processes, or use Node.js beyond basic scripting. A VPS (Virtual Private Server) is a dedicated virtual machine with a guaranteed slice of CPU and RAM. You get full root access, can install any software, run any process, and configure the server exactly as you need. VPS hosting is more expensive than shared hosting (typically $4–20/month vs $2–5/month) but required for Node.js applications with persistent server processes.

What is PM2 and why do I need it?

PM2 is a production process manager for Node.js. If you start your app with node server.js in an SSH session and close the terminal, the process dies. PM2 runs your app as a managed background service that: (1) keeps running after you disconnect, (2) restarts automatically if the app crashes, (3) starts automatically when the server reboots, (4) runs multiple instances in cluster mode to use all CPU cores, and (5) manages log files with rotation. You could use systemd directly instead of PM2, but PM2 is simpler for Node.js-specific workflows.

Why do I need Nginx if Node.js can serve HTTP directly?

Node.js can serve HTTP on port 3000 directly, but Nginx provides several benefits: (1) SSL termination — Nginx handles HTTPS and your Node.js app can stay plain HTTP internally, (2) static file serving — Nginx serves static files (CSS, JS, images) much faster than Node.js because it's purpose-built for it, (3) virtual hosting — you can run multiple apps on the same server by routing different domains to different ports, (4) security headers and rate limiting — Nginx adds these without changing application code, (5) you can't run non-root processes on ports 80 and 443 without special permissions — Nginx runs as root to bind those ports, then proxies to your Node.js process on a high port.

How do I deploy updates without downtime?

Use pm2 reload myapp instead of pm2 restart myapp. reload does a rolling restart in cluster mode — it starts new instances, waits for them to become ready and accept connections, then shuts down the old instances. The result is zero-downtime deployment. restart kills all instances immediately and then starts them, causing a brief period where the app is unavailable. For the reload to work correctly with cluster mode (instances: 'max'), your app should listen for the SIGINT signal and gracefully close existing connections before exiting.

Do I need to know Linux to deploy a Node.js app?

You need basic Linux comfort: navigating the filesystem, editing files with nano, running commands, understanding file permissions, and reading logs. You don't need to be a Linux administrator. The commands in this guide cover the 20% of Linux knowledge that handles 80% of deployment tasks. If you're completely new to Linux, spend a few hours with a basic Ubuntu terminal tutorial first — that investment will pay off across every server-side project you work on.