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:
- Node.js development experience (Express, Nest.js, or similar frameworks)
- Understanding of CI/CD fundamentals
- VPS server with Nginx setup
- GitHub Actions basics
- Domain configuration knowledge
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?
- Process management: Keeping your app running even if it crashes
- Environment configuration: Managing different settings for development vs production
- Database connections: Handling persistent connections that can break
- Memory management: Preventing memory leaks and resource exhaustion
- Load balancing: Running multiple instances for better performance
- 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 processscript
: The main file to run (your app's entry point)instances: "max"
: Run one process per CPU core for better performanceexec_mode: "cluster"
: Enable load balancing across multiple processesmax_memory_restart: "1G"
: Restart if memory usage exceeds 1GBmin_uptime: "10s"
: Consider the app stable after running 10 secondsmax_restarts: 5
: Stop trying to restart after 5 consecutive failureslog_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.