Series: Pure Node.js — Zero Dependencies

Post #12: Deploy Pure Node.js to a VPS

📅 March 2026 ⏱ 12 min read 🏷 Node.js, Linux, nginx, systemd, Deployment

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving → #12: Deploy Pure Node.js to a VPS (you are here)

The Final Step: Getting It Live

You've built it. Now let's ship it. In this final post we deploy our pure Node.js app to a Ubuntu VPS using systemd (to keep it running) and nginx (as a reverse proxy for HTTPS and static file serving).

You'll need: A Ubuntu 22.04+ VPS (DigitalOcean, Linode, Hetzner, or any provider), a domain name pointed to your VPS IP, and SSH access.

Step 1: Server Setup

# SSH into your VPS
ssh root@your-server-ip

# Update system
apt update && apt upgrade -y

# Install Node.js via NodeSource (LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs

# Install nginx
apt install -y nginx

# Create a dedicated user (don't run Node as root!)
useradd -m -s /bin/bash nodeapp
mkdir -p /home/nodeapp/app
chown nodeapp:nodeapp /home/nodeapp/app

Step 2: Deploy Your Code

# From your local machine, copy the app
rsync -avz --exclude 'node_modules' --exclude '.git' \
  ./my-app/ root@your-server-ip:/home/nodeapp/app/

# Or use git on the server
ssh root@your-server-ip
su - nodeapp
cd /home/nodeapp/app
git clone https://github.com/yourusername/your-app.git .

# No npm install needed — zero dependencies!
# Just verify the app runs
node server.js
# Ctrl+C to stop — we'll use systemd next

Step 3: Add Graceful Shutdown to Your Server

Before deploying, add graceful shutdown handling so active requests complete before the server exits:

// server.js — add this at the bottom

// Health check endpoint for monitoring
server.on('request', (req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      status: 'ok',
      uptime: process.uptime(),
      memory: process.memoryUsage(),
      timestamp: new Date().toISOString()
    }));
  }
});

// Graceful shutdown
let isShuttingDown = false;

function shutdown(signal) {
  if (isShuttingDown) return;
  isShuttingDown = true;

  console.log(`Received ${signal}. Graceful shutdown...`);

  server.close(() => {
    console.log('All connections closed. Exiting.');
    process.exit(0);
  });

  // Force exit after 10 seconds if connections don't close
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10_000);
}

process.on('SIGTERM', () => shutdown('SIGTERM')); // systemd stop
process.on('SIGINT', () => shutdown('SIGINT'));   // Ctrl+C

Step 4: systemd Service

systemd keeps your app running after crashes and restarts it on server reboot:

# Create the service file
nano /etc/systemd/system/nodeapp.service
[Unit]
Description=Pure Node.js App
After=network.target

[Service]
Type=simple
User=nodeapp
WorkingDirectory=/home/nodeapp/app
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5

# Environment variables
Environment=NODE_ENV=production
Environment=PORT=3000

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nodeapp

# Security hardening
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
# Enable and start the service
systemctl daemon-reload
systemctl enable nodeapp
systemctl start nodeapp

# Check status
systemctl status nodeapp

# View logs live
journalctl -u nodeapp -f

Step 5: nginx Reverse Proxy

nginx sits in front of Node.js, handles HTTPS, and serves static files efficiently:

# /etc/nginx/sites-available/nodeapp
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # Redirect HTTP to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    # SSL certificates (from Let's Encrypt — see below)
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Security headers
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";

    # Serve static files directly (bypasses Node.js = faster)
    location /static/ {
        alias /home/nodeapp/app/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location /uploads/ {
        alias /home/nodeapp/app/uploads/;
        expires 30d;
    }

    # Proxy everything else to Node.js
    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'; # for WebSockets
        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 site
ln -s /etc/nginx/sites-available/nodeapp /etc/nginx/sites-enabled/
nginx -t  # test config
systemctl reload nginx

# Get free HTTPS cert from Let's Encrypt
apt install -y certbot python3-certbot-nginx
certbot --nginx -d yourdomain.com -d www.yourdomain.com

Step 6: Zero-Downtime Deployment

# deploy.sh — run this on your local machine
#!/bin/bash
set -e

echo "Deploying to production..."

# Push code to server
rsync -avz --exclude 'node_modules' --exclude '.git' --exclude 'data' \
  ./ nodeapp@your-server-ip:/home/nodeapp/app/

# Restart gracefully via systemd
# systemd sends SIGTERM → our shutdown handler closes connections → exits
ssh root@your-server-ip "systemctl restart nodeapp"

# Wait and verify
sleep 2
curl -sf https://yourdomain.com/health | jq .

echo "Deploy complete!"

Environment Variables

// In your app, always use process.env
const PORT = parseInt(process.env.PORT) || 3000;
const NODE_ENV = process.env.NODE_ENV || 'development';
const SECRET_KEY = process.env.SECRET_KEY; // for session signing

if (!SECRET_KEY) {
  console.error('SECRET_KEY env var is required');
  process.exit(1);
}
# Add to systemd service, or create a .env file
# /home/nodeapp/app/.env (don't commit this to git!)
SECRET_KEY=your-super-secret-key-here
DATABASE_URL=...

# Load in systemd:
EnvironmentFile=/home/nodeapp/app/.env
Key takeaways:
  • Never run Node.js as root — create a dedicated system user
  • systemd's Restart=on-failure auto-restarts your app on crashes
  • Graceful shutdown: handle SIGTERM, finish active requests, then exit
  • nginx in front of Node handles HTTPS, rate limiting, and static files efficiently
  • Use proxy_set_header Upgrade for WebSocket proxying
  • /health endpoint for monitoring and deployment verification

🎉 Series Complete!

You've built four full web apps in pure Node.js — a Todo App, Dashboard, Real-time Chat, and Mini Store — with zero npm packages. You now understand exactly what Express, Socket.io, body-parser, multer, and express-session do under the hood. That's the foundation of a great engineer.

What You've Learned Across All 12 Posts

Finished the series? Show me what you built on X/Twitter or GitHub. Tag me — I'd love to see it!