Skip to content
KhaiziNam Blog KhaiziNam Blog
Go back
Đọc bằng tiếng Việt

How to Deploy Node.js and React Apps on Linux Hosting with Nginx Reverse Proxy (PM2 + SSL Guide 2025)

Nguyễn Hữu Khải - khaizinam

You built a Node.js API or React app and now you’re staring at a blank Linux VPS wondering how to actually get it live. Uploading files to shared hosting won’t work here - Node.js needs a process manager, a reverse proxy, and proper server configuration to run reliably in production. This guide walks you through the complete deployment stack: PM2 to keep your app alive, Nginx as a reverse proxy with proxy_pass, SSL via Let’s Encrypt, and a clean setup for React static builds - all from scratch on Ubuntu.

Table of Contents

1. What is Nginx Reverse Proxy and Why You Need It for Node.js

2. Prerequisites - What You Need Before Starting

3. Step 1 - Install Node.js on Ubuntu Linux

4. Step 2 - Set Up PM2 Process Manager

5. Step 3 - Install and Configure Nginx

6. Step 4 - Configure Nginx proxy_pass for Node.js API

7. Step 5 - Deploy React Build as Static Files with Nginx

8. Step 6 - Enable HTTPS with Let’s Encrypt (Certbot)

9. Step 7 - Host Multiple Node.js Apps on One Server

10. Common Errors and How to Fix Them

11. FAQ


What Is Nginx Reverse Proxy and Why You Need It for Node.js

Node.js runs your application on an internal port - typically 3000, 4000, or 5000. It cannot and should not be directly exposed to the internet on port 80 or 443. That’s where Nginx acts as a reverse proxy: it sits in front of your Node.js process, accepts all incoming traffic on port 80/443, and forwards it internally to your app via proxy_pass.

This architecture gives you several production-critical capabilities that Node.js alone cannot handle efficiently:

PM2 complements Nginx by keeping your Node.js process alive after crashes, auto-starting on server reboot, and providing process monitoring and log management.

See more: Deploy PHP, Laravel, CodeIgniter on Linux VPS with Nginx

Prerequisites - What You Need Before Starting

Step 1 - Install Node.js on Ubuntu Linux

Do not install Node.js from the default Ubuntu repository - it ships an outdated version. Use the NodeSource repository to get the current LTS release (Node.js 20.x at time of writing):

# Update package lists
sudo apt update && sudo apt upgrade -y

# Install curl if not present
sudo apt install -y curl git build-essential

# Add NodeSource repository for Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -

# Install Node.js and npm
sudo apt install -y nodejs

# Verify installation
node -v   # should show v20.x.x
npm -v    # should show 10.x.x

If you manage multiple Node.js projects requiring different versions, consider installing NVM (Node Version Manager) instead. It lets you switch between Node versions per project without reinstalling:

# Install NVM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc

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

Step 2 - Set Up PM2 Process Manager

PM2 is the industry standard process manager for Node.js in production. Without it, your app dies the moment your SSH session ends or the server reboots. Install it globally:

sudo npm install -g pm2

Clone your application and start it with PM2:

# Clone your repository
cd /var/www
git clone https://github.com/yourusername/your-node-app.git
cd your-node-app

# Install dependencies
npm install --production

# Start app with PM2 (replace app.js with your entry file)
pm2 start app.js --name "my-node-app"

# Or for an Express app with specific port
PORT=3000 pm2 start app.js --name "my-node-app"

# Save PM2 process list
pm2 save

# Configure PM2 to start on system boot
pm2 startup
# Copy and run the command PM2 outputs (it looks like:)
# sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u youruser --hp /home/youruser

Essential PM2 commands you’ll use daily:

pm2 list                    # List all running processes
pm2 logs my-node-app        # Tail logs in real time
pm2 restart my-node-app     # Restart after code changes
pm2 stop my-node-app        # Stop the process
pm2 delete my-node-app      # Remove from PM2 list
pm2 monit                   # Real-time CPU/memory dashboard

For production apps, use PM2 cluster mode to utilize all CPU cores:

# Start with cluster mode - spawns one instance per CPU core
pm2 start app.js --name "my-node-app" -i max

# Or specify exact number of instances
pm2 start app.js --name "my-node-app" -i 4

Step 3 - Install and Configure Nginx

# Install Nginx
sudo apt install -y nginx

# Enable Nginx to start on boot
sudo systemctl enable nginx

# Start Nginx
sudo systemctl start nginx

# Verify it's running
sudo systemctl status nginx

# Configure firewall - allow HTTP, HTTPS, and SSH
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status

Nginx configuration lives in two directories on Ubuntu:

This separation lets you draft and test configurations without activating them.

Step 4 - Configure Nginx proxy_pass for Node.js API

Create a new Nginx config file for your Node.js application. Replace yourdomain.com with your actual domain and 3000 with the port your app runs on:

sudo nano /etc/nginx/sites-available/yourdomain.com

Paste the following configuration:

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

    # Logging
    access_log /var/log/nginx/yourdomain.access.log;
    error_log  /var/log/nginx/yourdomain.error.log;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # Required for WebSocket support
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection 'upgrade';

        # Pass real client info to Node.js app
        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_redirect     off;

        # Timeouts - increase for long-running requests
        proxy_read_timeout  240s;
        proxy_send_timeout  240s;
        proxy_connect_timeout 75s;
    }
}

Enable the site and reload Nginx:

# Create symlink to enable the site
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/

# Test configuration syntax - always do this before reloading
sudo nginx -t

# If test passes, reload Nginx
sudo systemctl reload nginx

Your Node.js app is now accessible at http://yourdomain.com. The proxy_pass http://127.0.0.1:3000 line is the core of the reverse proxy - all traffic to port 80 is forwarded to your Node.js process on port 3000 internally.

Why use 127.0.0.1 instead of localhost in proxy_pass?

Using 127.0.0.1 forces IPv4 resolution and avoids a subtle bug where systems with IPv6 enabled resolve localhost to ::1 (IPv6 loopback) while your Node.js app only listens on IPv4 - resulting in a connection refused error even though both Nginx and Node.js are running correctly.

Step 5 - Deploy React Build as Static Files with Nginx

React (and other SPA frameworks like Vue, Next.js static export) should be served as pre-built static files - not proxied through Node.js. This is significantly faster and puts zero load on your application server.

Build your React app locally or on the server:

# On your server or in your CI pipeline
cd /var/www/your-react-app
npm install
npm run build
# Output is in /var/www/your-react-app/build (CRA) or /dist (Vite)

Create an Nginx config to serve the static build and optionally proxy API calls to your Node.js backend:

sudo nano /etc/nginx/sites-available/app.yourdomain.com
server {
    listen 80;
    server_name app.yourdomain.com;

    # Root directory - point to your React build output
    root /var/www/your-react-app/build;
    index index.html;

    access_log /var/log/nginx/react-app.access.log;
    error_log  /var/log/nginx/react-app.error.log;

    # Serve React app - handle client-side routing
    # Without this, refreshing on /dashboard returns 404
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy /api requests to Node.js backend
    # React app calls /api/users -> forwarded to Node.js on port 3000
    location /api {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
    }

    # Cache static assets aggressively
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires    1y;
        add_header Cache-Control "public, immutable";
    }
}

The try_files $uri $uri/ /index.html directive is critical for React Router - without it, navigating directly to /dashboard returns a 404 because Nginx looks for a file called dashboard on disk and finds nothing. This line tells Nginx: if the file doesn’t exist, serve index.html and let React Router handle the routing client-side.

Step 6 - Enable HTTPS with Let’s Encrypt (Certbot)

Never run production apps over plain HTTP. Let’s Encrypt provides free SSL certificates via Certbot, which also auto-configures Nginx:

# Install Certbot and Nginx plugin
sudo apt install -y certbot python3-certbot-nginx

# Allow HTTPS through firewall
sudo ufw allow 'Nginx Full'

# Issue certificate and auto-configure Nginx
# Replace with your actual domain(s)
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Follow the prompts:

Certbot automatically modifies your Nginx config to add HTTPS and set up HTTP-to-HTTPS redirect. Your config will gain a new server block listening on port 443 with the SSL certificate paths.

Certificates are valid for 90 days. Certbot installs a systemd timer that auto-renews before expiry. Test auto-renewal with:

sudo certbot renew --dry-run

Step 7 - Host Multiple Node.js Apps on One Server

One of Nginx’s biggest advantages: run many apps on the same server, each on a different domain or subdomain, each on a different internal port. Create a separate config file per app:

# App 1 - API on port 3000
sudo nano /etc/nginx/sites-available/api.yourdomain.com

# App 2 - Admin panel on port 4000
sudo nano /etc/nginx/sites-available/admin.yourdomain.com

# App 3 - React frontend (static)
sudo nano /etc/nginx/sites-available/app.yourdomain.com

Example for running two separate Node.js APIs:

# /etc/nginx/sites-available/api.yourdomain.com
server {
    listen 80;
    server_name api.yourdomain.com;
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
    }
}
# /etc/nginx/sites-available/admin.yourdomain.com
server {
    listen 80;
    server_name admin.yourdomain.com;
    location / {
        proxy_pass http://127.0.0.1:4000;
        proxy_http_version 1.1;
        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;
    }
}

Enable both and reload:

sudo ln -s /etc/nginx/sites-available/api.yourdomain.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/admin.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Common Errors and How to Fix Them

FAQ

Q: Do I need PM2 if I’m using Docker?

A: No. Docker handles process lifecycle - if your container crashes, Docker restarts it (with --restart unless-stopped). Inside a Docker container, run Node.js directly as the main process (CMD ["node", "app.js"]). PM2 is for bare-metal or VPS deployments without containerization.

Q: Can I deploy a Next.js app the same way?

A: Yes, with one distinction. Next.js in production runs as a Node.js server (npm run start on port 3000 by default) - proxy_pass to it exactly like any Node.js app. If you use next export for a fully static build, serve it like the React static example above. For App Router with server components, always use the Node.js server mode.

Q: How do I update my app without downtime?

A: Pull new code, install dependencies, then use PM2 reload (not restart) for zero-downtime deploys: git pull && npm install --production && pm2 reload my-node-app. PM2 reload gracefully cycles workers one by one, keeping the app serving traffic throughout.

Q: What port should my Node.js app listen on?

A: Any port above 1024 that isn’t already in use - 3000, 4000, 5000, 8000 are all common choices. Check occupied ports with sudo ss -tlnp. The external port (80/443) is handled entirely by Nginx - your Node.js app never needs to know about it.

Q: How do I set environment variables for production?

A: Create a .env file in your app directory and use dotenv in your app, or pass variables directly via PM2 ecosystem file. Create ecosystem.config.js in your project root and run pm2 start ecosystem.config.js. This keeps environment config separate from application code and easy to manage per environment.

Wrapping Up - Your Deployment Stack Is Now Production-Ready

You now have the complete deployment stack: Node.js running under PM2, Nginx as a reverse proxy handling all public traffic via proxy_pass, HTTPS enforced by Let’s Encrypt, React static assets served directly by Nginx, and the ability to host multiple apps on a single server. This is the same stack used by the vast majority of Node.js deployments in production worldwide.

Your next step: Set up a simple deployment script - a shell script or GitHub Action that SSH’s into your server, pulls latest code, runs npm install, and calls pm2 reload. Automating deployment from day one eliminates human error and makes updates take seconds instead of minutes. Check out how to set up CI/CD deployment for Node.js with GitHub Actions for the complete automation guide.

See more: Deploy PHP, Laravel, CodeIgniter on Linux VPS with Nginx


Share this post:

Related Posts

Why I Quit WordPress for Astro + Cloudflare Pages (And Cut My Hosting Bill to Zero)

I quit WordPress and switched to Astro + Cloudflare Pages + R2 to eliminate almost all hosting costs for my personal blog - no more malware, plugin...

Secrets to Deploying React/Node.js on a 1GB RAM VPS with PM2: Never Run Out of Memory 2026

For web developers, owning a 1GB RAM VPS at a low cost is an ideal starting point. However, in practice, deployment often turns into a "nightmare" when...

Deploy PHP, Laravel, CodeIgniter on Linux VPS with Nginx + MySQL Docker + SSL 2026

You just spun up a Linux VPS and you're staring at a blank terminal not knowing where to start? Shared hosting works fine for simple PHP - but the moment...

Optimizing Project Environment Setup Time with Docker for Low-Config VPS

How to optimize project environment setup time with Docker for a 2-Core 4GB RAM VPS. Guide to the smoothest Nginx, PHP, MySQL, and Redis configuration....

Ninety Thousand Acres: How I Turned the Tables on a 1 vs 4 Siege With Pure Psychology

Surrounded by 4 stronger kingdoms in Ninety Thousand Acres? Here's how I used the truce window, the daily attack hours, and pure psychological warfare to...

In Depth Review of Ninety Thousand Acres

Ninety Thousand Acres (Chinese: 九萬畝) is a mobile simulation management and strategy (SLG) game published by DH-Publisher, heavily inspired by the Three...

IT Job Benefits Beyond Salary - What Freshers Always Miss

Salary is just one part of total compensation. Here are the IT job benefits freshers consistently overlook when evaluating an offer - and how to ask without...

Outsource vs Product Company for Junior Devs: Which Should You Choose?

Outsource companies pay 20–35% less but are much easier to get into - product companies offer more depth but set a higher bar. A real comparison to help you...

Gothic Sketch AI Prompt: Turn Your Photo into Sketchbook Art

A complete gothic sketch AI prompt to transform your portrait into dark sketchbook art - copy, paste, done. Works on ChatGPT, Midjourney, and Stable...

Rejecting an IT Job Offer: 5 Red Flags Freshers Must Know

5 red flags in IT job offers every fresher and junior must recognize. Knowing when to say no protects you from wasting 6–12 months in the wrong environment.


Previous Post
IT Interview Self Introduction Scripts: .NET, Java, React, Node
Next Post
Session vs JWT: The Complete Theory and Most Common Interview Questions Every Junior Developer Must Know