Skip to main content

Node.js Application Deployment with GitHub Actions and VPS

Node.js applications require runtime environments and process management for production deployment. Unlike static React apps, Node.js applications run as persistent server processes that handle API requests, database connections, and real-time features.

This guide covers automated deployment of various Node.js frameworks to VPS servers using GitHub Actions, PM2 for process management, and Nginx as a reverse proxy.

Understanding Node.js Deployment: Complete Beginner's Guide

What you'll learn:

  • Why Node.js deployment is different from static website deployment
  • What PM2 is and why Node.js applications need process management
  • How to set up automated deployment for Express.js and Nest.js applications
  • How to configure Nginx as a reverse proxy for Node.js apps
  • How to manage multiple Node.js applications on one server

Prerequisites:

Why is Node.js deployment different?

Static websites (like built React apps):

  • Files sit on the server and don't change
  • Web server (Nginx) directly serves HTML/CSS/JS files
  • No ongoing processes running
  • Like a library—books just sit on shelves waiting to be read

Node.js applications:

  • Code actively runs on the server 24/7
  • Handles incoming requests in real-time
  • Maintains database connections and state
  • Can crash and need to be restarted
  • Like a restaurant kitchen—actively cooking and serving orders

What makes Node.js deployment complex?

  1. Process management: Keeping your app running even if it crashes
  2. Environment configuration: Managing different settings for development vs production
  3. Database connections: Handling persistent connections that can break
  4. Memory management: Preventing memory leaks and resource exhaustion
  5. Load balancing: Running multiple instances for better performance
  6. Graceful shutdowns: Properly stopping the app during deployments

Node.js Deployment Architecture

Key Components:

  • Node.js Runtime: Server environment for JavaScript execution
  • PM2 Process Manager: Keeps Node.js applications running, handles restarts and logging
  • Nginx Reverse Proxy: Routes traffic to Node.js applications and serves static files
  • GitHub Actions: Automates testing, building, and deployment

Common Node.js Frameworks:

  • Express.js: Minimal web framework for APIs and web applications
  • Nest.js: Enterprise-grade framework with TypeScript support
  • Fastify: High-performance alternative to Express
  • Koa.js: Next-generation framework from Express team

Express.js Application Deployment

Project Structure

my-express-app/
├── src/
│ ├── app.js # Main application file
│ ├── routes/ # Route handlers
│ └── middleware/ # Custom middleware
├── public/ # Static files (images, CSS, JS)
├── package.json # Dependencies and scripts
├── .env.example # Environment variables template
├── ecosystem.config.js # PM2 configuration
└── .github/workflows/
└── deploy.yml # GitHub Actions workflow

Express.js Application Setup

src/app.js - Production-ready Express app:

const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const path = require("path");
require("dotenv").config();

const app = express();
const PORT = process.env.PORT || 3000;

// Security and logging middleware
app.use(helmet());
app.use(cors());
app.use(morgan("combined"));
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));

// Serve static files
app.use(express.static(path.join(__dirname, "../public")));

// Health check endpoint
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});

// API routes
app.get("/api/users", (req, res) => {
res.json({ message: "Users API endpoint" });
});

// Error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: "Something went wrong!" });
});

// 404 handler
app.use("*", (req, res) => {
res.status(404).json({ error: "Route not found" });
});

app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

module.exports = app;

PM2 Configuration

What is PM2? PM2 (Process Manager 2) is a production-ready process manager for Node.js applications. Think of it as a supervisor that:

  • Keeps your app running: Automatically restarts if it crashes
  • Manages multiple processes: Runs multiple copies of your app for better performance
  • Handles logging: Collects and rotates log files automatically
  • Monitors performance: Tracks memory usage and CPU consumption
  • Zero-downtime deployments: Updates your app without stopping service

Why use PM2? Without PM2, if your Node.js app crashes, it stays down until you manually restart it. PM2 ensures your application is always available to users.

ecosystem.config.js - PM2 process configuration:

module.exports = {
apps: [
{
name: "my-express-app",
script: "src/app.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3000,
},
env_production: {
NODE_ENV: "production",
PORT: 3000,
},
// Process management
max_memory_restart: "1G",
min_uptime: "10s",
max_restarts: 5,
// Logging
log_file: "./logs/combined.log",
out_file: "./logs/out.log",
error_file: "./logs/error.log",
log_date_format: "YYYY-MM-DD HH:mm Z",
// Monitoring
monitoring: false,
},
],
};

Configuration Explained:

  • name: A friendly name for your application process
  • script: The main file to run (your app's entry point)
  • instances: "max": Run one process per CPU core for better performance
  • exec_mode: "cluster": Enable load balancing across multiple processes
  • max_memory_restart: "1G": Restart if memory usage exceeds 1GB
  • min_uptime: "10s": Consider the app stable after running 10 seconds
  • max_restarts: 5: Stop trying to restart after 5 consecutive failures
  • log_file: Where to save all logs (combined output and errors)
  • monitoring: false: Disable PM2's built-in monitoring dashboard

GitHub Actions Workflow for Express.js

.github/workflows/deploy-express.yml:

name: Deploy Express.js App

on:
push:
branches: [main]
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

- name: Run linting
run: npm run lint

deploy:
needs: test
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

- name: Deploy to VPS
run: |
# Create deployment directory
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "mkdir -p /var/www/my-express-app"

# Sync application files (excluding node_modules)
# rsync explanation:
# -a = archive mode (preserves permissions, timestamps, etc.)
# -v = verbose (shows what files are being transferred)
# -z = compress during transfer (faster over internet)
# --delete = remove files on server that don't exist locally
# --exclude = skip these files/folders
rsync -avz --delete \
--exclude 'node_modules' \
--exclude '.git' \
--exclude '.env' \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/my-express-app/

# Install dependencies and restart application
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
cd /var/www/my-express-app
npm ci --only=production

# Create logs directory
mkdir -p logs

# Create environment file
echo "NODE_ENV=production" > .env
echo "PORT=3000" >> .env
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env

# Restart with PM2
pm2 reload ecosystem.config.js --env production || pm2 start ecosystem.config.js --env production
pm2 save
EOF

Nginx Configuration for Express.js

Why Nginx with Node.js? Nginx acts as a "reverse proxy" - it receives requests from users and forwards them to your Node.js app. This setup provides:

  • Better performance: Nginx handles static files (images, CSS) faster than Node.js
  • Security: Nginx provides an extra layer of protection
  • Load balancing: Can distribute requests across multiple Node.js processes
  • SSL termination: Nginx handles HTTPS certificates

/etc/nginx/sites-available/express-app:

server {
listen 80; # Listen on port 80 (HTTP)
server_name your-domain.com www.your-domain.com; # Your domain name

# Serve static files directly (images, CSS, JS)
# This is faster than letting Node.js handle them
location /static/ {
alias /var/www/my-express-app/public/; # Where static files are stored
expires 1y; # Cache for 1 year
add_header Cache-Control "public, immutable"; # Tell browsers to cache
}

# Forward all other requests to Node.js app
location / {
proxy_pass http://localhost:3000; # Forward to Node.js on port 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;

# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}

# Health check endpoint (bypass proxy cache)
location /health {
proxy_pass http://localhost:3000;
proxy_no_cache 1;
proxy_cache_bypass 1;
}

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

Nest.js Application Deployment

Nest.js Project Structure

my-nestjs-app/
├── src/
│ ├── main.ts # Application entry point
│ ├── app.module.ts # Root module
│ ├── controllers/ # API controllers
│ ├── services/ # Business logic
│ └── entities/ # Database entities
├── dist/ # Compiled output
├── package.json
├── tsconfig.json
├── ecosystem.config.js
└── .github/workflows/
└── deploy-nestjs.yml

Nest.js Production Configuration

src/main.ts - Production-ready Nest.js bootstrap:

import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";
import helmet from "helmet";

async function bootstrap() {
const app = await NestFactory.create(AppModule);

// Security
app.use(helmet());

// CORS configuration
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(",") || [
"http://localhost:3000",
],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
});

// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);

// API documentation (only in development)
if (process.env.NODE_ENV === "development") {
const config = new DocumentBuilder()
.setTitle("API Documentation")
.setDescription("The API description")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
}

// Global prefix
app.setGlobalPrefix("api");

const port = process.env.PORT || 3000;
await app.listen(port, "0.0.0.0");

console.log(`Application is running on: http://localhost:${port}`);
console.log(`Health check: http://localhost:${port}/api/health`);
}

bootstrap();

GitHub Actions Workflow for Nest.js

.github/workflows/deploy-nestjs.yml:

name: Deploy Nest.js App

on:
push:
branches: [main]
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test

- name: Run e2e tests
run: npm run test:e2e

- name: Type checking
run: npm run build

deploy:
needs: test
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"

- name: Build application
run: |
npm ci
npm run build

- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

- name: Deploy to VPS
run: |
# Create deployment directory
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "mkdir -p /var/www/my-nestjs-app"

# Sync built application
rsync -avz --delete \
--include 'dist/' \
--include 'node_modules/' \
--include 'package*.json' \
--include 'ecosystem.config.js' \
--exclude '*' \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/my-nestjs-app/

# Install production dependencies and restart
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
cd /var/www/my-nestjs-app
npm ci --only=production

# Create environment file
echo "NODE_ENV=production" > .env
echo "PORT=3000" >> .env
echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env

# Restart application
pm2 reload ecosystem.config.js --env production || pm2 start ecosystem.config.js --env production
pm2 save
EOF

Multi-Application Node.js Deployment

Port Management Strategy

# Port allocation for multiple Node.js apps
App 1 (Main API): 3000
App 2 (Admin API): 3001
App 3 (User Portal): 3002
App 4 (Analytics): 3003

Multi-App PM2 Configuration

ecosystem.config.js - Multiple applications:

module.exports = {
apps: [
{
name: "main-api",
script: "dist/main.js",
cwd: "/var/www/main-api",
env: { NODE_ENV: "production", PORT: 3000 },
},
{
name: "admin-api",
script: "dist/main.js",
cwd: "/var/www/admin-api",
env: { NODE_ENV: "production", PORT: 3001 },
},
{
name: "user-portal",
script: "src/app.js",
cwd: "/var/www/user-portal",
env: { NODE_ENV: "production", PORT: 3002 },
},
{
name: "analytics-service",
script: "dist/main.js",
cwd: "/var/www/analytics-service",
env: { NODE_ENV: "production", PORT: 3003 },
},
],
};

Nginx Configuration for Multiple Node.js Apps

How Multiple Apps Work: When you have multiple Node.js applications, each runs on a different port (3000, 3001, 3002, etc.). Nginx uses "upstreams" to define where each app is running, then routes requests based on the domain name.

/etc/nginx/sites-available/nodejs-multiapp:

# Define where each app is running (upstream blocks)
upstream main_api {
server 127.0.0.1:3000; # Main API runs on port 3000
}

upstream admin_api {
server 127.0.0.1:3001; # Admin API runs on port 3001
}

upstream user_portal {
server 127.0.0.1:3002; # User Portal runs on port 3002
}

# Analytics Service
upstream analytics_service {
server 127.0.0.1:3003;
}

# Main API server
server {
listen 80;
server_name api.yourdomain.com;

location / {
proxy_pass http://main_api;
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;
}
}

# Admin API server
server {
listen 80;
server_name admin-api.yourdomain.com;

location / {
proxy_pass http://admin_api;
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;
}
}

# User Portal server
server {
listen 80;
server_name portal.yourdomain.com;

location / {
proxy_pass http://user_portal;
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;
}
}

Production Server Setup for Node.js

Install Node.js and PM2

# Install Node.js (using NodeSource repository)
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs

# Verify installation
node --version
npm --version

# Install PM2 globally
sudo npm install -g pm2

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

# Install PM2 log rotate
pm2 install pm2-logrotate

Environment Variables Management

Create secure environment files:

# Create environment directory
sudo mkdir -p /var/www/env

# Create environment file for each app
sudo tee /var/www/env/main-api.env << EOF
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/maindb
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key
API_KEY=your-api-key
EOF

# Set secure permissions
sudo chmod 600 /var/www/env/*.env
sudo chown deploy:deploy /var/www/env/*.env

Database Configuration

PostgreSQL setup for Node.js apps:

# Install PostgreSQL
sudo apt install postgresql postgresql-contrib

# Create database and user
sudo -u postgres psql << EOF
CREATE USER deploy WITH PASSWORD 'secure-password';
CREATE DATABASE maindb OWNER deploy;
GRANT ALL PRIVILEGES ON DATABASE maindb TO deploy;
\q
EOF

# Test connection
psql -h localhost -U deploy -d maindb -c "SELECT version();"

Process Monitoring

PM2 monitoring commands:

# List all processes
pm2 list

# Monitor processes in real-time
pm2 monit

# View logs
pm2 logs
pm2 logs main-api
pm2 logs --lines 100

# Restart applications
pm2 restart all
pm2 restart main-api

# Reload applications (zero-downtime)
pm2 reload all

# Stop applications
pm2 stop all
pm2 delete all

Performance Optimization

PM2 Cluster Mode

// ecosystem.config.js - Optimized for production
module.exports = {
apps: [
{
name: "my-app",
script: "dist/main.js",
instances: "max", // Use all CPU cores
exec_mode: "cluster",

// Performance settings
node_args: "--max-old-space-size=2048",
max_memory_restart: "1G",

// Health monitoring
min_uptime: "10s",
max_restarts: 5,
autorestart: true,

// Environment variables
env: {
NODE_ENV: "production",
PORT: 3000,
UV_THREADPOOL_SIZE: 128,
},
},
],
};

Nginx Caching for APIs

# Add caching for API responses
location /api/ {
proxy_pass http://nodejs_backend;

# Cache configuration
proxy_cache api_cache;
proxy_cache_valid 200 5m;
proxy_cache_valid 404 1m;
proxy_cache_key "$request_method$request_uri$is_args$args";
proxy_cache_bypass $http_cache_control;

add_header X-Cache-Status $upstream_cache_status;
}

# Cache zone configuration (add to http block)
http {
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=100m inactive=60m;
}

Security Best Practices

Application Security

// Security middleware for Express.js
const express = require("express");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");

const app = express();

// Security headers
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
})
);

// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: "Too many requests from this IP",
});

app.use("/api/", limiter);

Environment Security

# Secure file permissions
sudo chmod 600 /var/www/*/.*env
sudo chmod 700 /var/www/*/logs
sudo chown -R deploy:deploy /var/www/*

# Firewall rules for Node.js ports
sudo ufw allow 22 # SSH
sudo ufw allow 80 # HTTP
sudo ufw allow 443 # HTTPS
sudo ufw deny 3000 # Block direct access to Node.js
sudo ufw deny 3001
sudo ufw deny 3002
sudo ufw enable

This comprehensive guide covers Node.js deployment patterns for modern applications. The combination of PM2 process management, Nginx reverse proxy, and automated GitHub Actions workflows provides a robust, scalable deployment solution for Express.js, Nest.js, and other Node.js frameworks.