How I Deploy Node.js Apps to Production (2026)
My complete deployment guide — from code to production in minutes.
The Stack I Use
App: Node.js + Express
Database: SQLite (or PostgreSQL for larger apps)
Process Manager: systemd
Reverse Proxy: Nginx
SSL: Let's Encrypt (free, auto-renewing)
Server: $5-10/month VPS (DigitalOcean, Hetzner, etc.)
CI/CD: GitHub Actions (free for public repos)
Step 1: Prepare the App
# Make sure your app has:
# - package.json with start script
# - .env.example (template, no secrets!)
# - .gitignore (includes node_modules, .env, dist)
# - Build script (if TypeScript)
# package.json
{
"scripts": {
"start": "node dist/server.js",
"build": "tsc",
"dev": "tsx watch server.ts"
}
}
Step 2: Set Up the Server
# SSH into your server
ssh root@your-server-ip
# Update system
apt update && apt upgrade -y
# Install Node.js (via nvm)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22
nvm alias default 22
# Install PM2 globally (process manager)
npm install -g pm2
# Install Nginx
apt install nginx -y
# Install Certbot (for SSL)
apt install certbot python3-certbot-nginx -y
# Create app directory
mkdir -p /var/www/myapp
chown -R $USER:$USER /var/www/myapp
Step 3: Deploy the Code
# Option A: Git pull (simplest)
cd /var/www/myapp
git clone https://github.com/yourname/myapp.git .
npm ci --production
npm run build # If TypeScript
# Option B: GitHub Actions (automated)
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --production
npm run build
pm2 restart myapp
Step 4: Process Management
# Start with PM2
cd /var/www/myapp
pm2 start npm --name "myapp" -- start
# Useful PM2 commands
pm2 list # List all processes
pm2 logs myapp # View logs
pm2 logs myapp --lines 100 # Last 100 lines
pm2 restart myapp # Restart
pm2 stop myapp # Stop
pm2 delete myapp # Remove
pm2 monit # Real-time monitoring
# Save PM2 config (survives reboot)
pm2 startup # Generates startup script
pm2 save # Save current process list
# Or use ecosystem.config.js for configuration
module.exports = {
apps: [{
name: 'myapp',
script: 'npm',
args: 'start',
cwd: '/var/www/myapp',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
}],
};
Step 5: Nginx Reverse Proxy
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name myapp.com www.myapp.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_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# Enable site
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
nginx -t # Test config
systemctl reload nginx
Step 6: SSL with Let's Encrypt
# Get free SSL certificate (auto-renewing!)
certbot --nginx -d myapp.com -d www.myapp.com
# Certbot auto-modifies your Nginx config to:
# - Redirect HTTP to HTTPS
# - Add SSL certificate paths
# - Add security headers
# Test auto-renewal
certbot renew --dry-run
# Auto-renewal is set up by certbot automatically
# Check: systemctl status certbot.timer
Step 7: Security Hardening
# Add to your Nginx server block:
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
# Block common attack paths
location ~ /\. {
deny all;
}
# Firewall (UFW)
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw enable
# Fail2Ban (auto-block suspicious IPs)
apt install fail2ban -y
systemctl enable fail2ban
# Disable root login
# Edit /etc/ssh/sshd_config:
# PermitRootLogin no
# PasswordAuthentication no (use SSH keys only!)
systemctl restart sshd
Step 8: Monitoring
# PM2 monitoring
pm2 monit
# Server resources
htop
df -h
free -h
# Application logs
pm2 logs myapp
# Nginx access logs
tail -f /var/log/nginx/access.log
# Nginx error logs
tail -f /var/log/nginx/error.log
# Set up log rotation (prevent disk fill-up!)
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
}
Step 9: Environment Variables
# Create .env file on server (NOT in git!)
cat > /var/www/myapp/.env << EOF
NODE_ENV=production
PORT=3000
DATABASE_URL=/var/www/myapp/data.db
JWT_SECRET=your-random-secret-here
EOF
# Set permissions
chmod 600 .env
chown $USER:$USER .env
# In your app, use dotenv:
# npm install dotenv
// .env is loaded automatically in production
Deployment Checklist
□ App builds without errors
□ .env.example committed (no secrets!)
□ .env created on server with production values
□ Process manager configured (PM2/systemd)
□ Nginx reverse proxy working
□ SSL certificate installed
□ HTTP redirects to HTTPS
□ Security headers configured
□ Rate limiting enabled
□ Firewall configured (UFW)
□ SSH key-only authentication
□ Log rotation set up
□ Monitoring in place
□ Backup strategy for database
□ Auto-restart on crash (PM2)
□ Auto-restart on server reboot (PM2 startup)
□ GitHub Actions CI/CD configured (optional but recommended)
What's your deployment setup? Any tips?
Follow @armorbreak for more DevOps content.
Top comments (0)