NestJS Zero-Downtime Deployment on DigitalOcean with GitLab CI/CD and PM2

NestJS Zero-Downtime Deployment on DigitalOcean with GitLab CI/CD and PM2

# nestjs# devops# gitlab# digitalocean
NestJS Zero-Downtime Deployment on DigitalOcean with GitLab CI/CD and PM2Nathaniel Toju

A complete, battle-tested guide to deploying a NestJS application to a DigitalOcean Droplet using GitLab CI/CD pipelines and PM2 cluster mode for zero-downtime deployments.

Deploying a NestJS backend to production with true zero downtime is more involved than most guides suggest. This is a complete, battle-tested walkthrough — built from real production experience — covering everything from Droplet setup to automated GitLab pipelines that deploy without dropping a single request.

Stack:

  • Server: DigitalOcean Droplet (Ubuntu 24.04)
  • Runtime: Node.js v18
  • Process Manager: PM2 (cluster mode)
  • Reverse Proxy: Nginx
  • CI/CD: GitLab CI/CD
  • Deploy user: deployer (non-root, for security)

Part 1 — Droplet Initial Setup

SSH into your Droplet as root and run the following steps once.

Install Node.js v18

⚠️ Warning
Do NOT use nodesource setup scripts — they may install v20 regardless of the version you specify. Install directly from the official Node.js binary to guarantee the version.

# Remove any existing Node.js installation
apt-get purge -y nodejs npm
apt-get autoremove -y
apt-get autoclean
rm -f /etc/apt/sources.list.d/nodesource.list
rm -f /etc/apt/sources.list.d/node*.list
rm -f /etc/apt/keyrings/nodesource.gpg
rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx
apt-get update

# Download and install Node.js v18 directly from nodejs.org
cd /tmp
curl -fsSL https://nodejs.org/dist/v18.20.8/node-v18.20.8-linux-x64.tar.xz -o node18.tar.xz
tar -xJf node18.tar.xz
cp -r node-v18.20.8-linux-x64/bin/* /usr/local/bin/
cp -r node-v18.20.8-linux-x64/lib/* /usr/local/lib/
cp -r node-v18.20.8-linux-x64/include/* /usr/local/include/

# Verify
node -v   # v18.20.8
npm -v
Enter fullscreen mode Exit fullscreen mode

Install PM2 and Nginx

npm install -g pm2
apt-get install -y nginx
Enter fullscreen mode Exit fullscreen mode

Create a non-root deploy user

Never deploy as root. Create a dedicated deployer user:

useradd -m -s /bin/bash deployer

# Create app directory
mkdir -p /var/www/nestapp
chown -R deployer:deployer /var/www/nestapp

# Create PM2 log directory
mkdir -p /var/log/pm2
chown -R deployer:deployer /var/log/pm2
Enter fullscreen mode Exit fullscreen mode

Set up SSH for the deployer user

mkdir -p /home/deployer/.ssh
chmod 700 /home/deployer/.ssh
touch /home/deployer/.ssh/authorized_keys
chmod 600 /home/deployer/.ssh/authorized_keys
chown -R deployer:deployer /home/deployer/.ssh
Enter fullscreen mode Exit fullscreen mode

Configure the firewall

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw --force enable
Enter fullscreen mode Exit fullscreen mode

Part 2 — Nginx Configuration

Create the Nginx site config

nano /etc/nginx/sites-available/nestapp
Enter fullscreen mode Exit fullscreen mode

Paste the following, replacing your-domain.com with your actual domain or Droplet IP:

upstream nestapp {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    listen [::]:80;
    server_name your-domain.com;

    location / {
        proxy_pass http://nestapp;
        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_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        'upgrade';
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 240s;
        proxy_connect_timeout 10s;
    }

    location /health {
        proxy_pass http://nestapp;
        access_log off;
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable the site

# Symlink to enable
ln -s /etc/nginx/sites-available/nestapp /etc/nginx/sites-enabled/nestapp

# Remove default site
rm /etc/nginx/sites-enabled/default

# Fix file permissions (important — Nginx can't read files owned only by root)
chmod 644 /etc/nginx/sites-available/nestapp

# Test config
nginx -t

# Reload Nginx
systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

SSL with Certbot

Once your domain DNS is pointing to the Droplet:

apt install certbot python3-certbot-nginx -y
certbot --nginx -d your-domain.com
Enter fullscreen mode Exit fullscreen mode

ℹ️ Note
Make sure dig your-domain.com +short returns your Droplet IP before running certbot, otherwise the ACME challenge will fail.


Part 3 — Project File Setup

ecosystem.config.js

Add this to your project root and commit it to GitLab:

module.exports = {
  apps: [
    {
      name: 'nestapp',
      script: 'dist/main.js',
      instances: 'max',
      exec_mode: 'cluster',
      node_args: '--security-revert=CVE-2023-46809', // remove if not needed
      autorestart: true,
      watch: false,
      max_memory_restart: '512M',
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
      out_file: '/var/log/pm2/nestapp-out.log',
      error_file: '/var/log/pm2/nestapp-error.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      merge_logs: true,
      kill_timeout: 5000,
      listen_timeout: 8000,
      wait_ready: true,
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Update src/main.ts

Signal PM2 when each worker is ready — this is what makes zero-downtime reload actually work:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });

  await app.listen(process.env.PORT || 3000);

  // Tell PM2 this worker is ready to receive traffic
  if (process.send) {
    process.send('ready');
  }
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

Update package.json — disable Husky in production

Without this, npm ci on the server will fail because Husky tries to install git hooks:

"scripts": {
  "prepare": "husky install || true"
}
Enter fullscreen mode Exit fullscreen mode

.gitlab-ci.yml

Add this to your project root:

image: node:18-alpine

stages:
  - install
  - lint
  - test
  - build
  - deploy

cache:
  key:
    files:
      - package-lock.json
  paths:
    - node_modules/
  policy: pull-push

workflow:
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_MERGE_REQUEST_IID

variables:
  NODE_ENV: test

install:
  stage: install
  script:
    - npm ci --prefer-offline
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

lint:
  stage: lint
  needs: [install]
  script:
    - npm run lint

test:
  stage: test
  needs: [install]
  script:
    - npm run test -- --passWithNoTests

build:
  stage: build
  needs: [lint, test]
  script:
    - npm run build
    - mkdir -p release
    - cp -r dist package.json package-lock.json ecosystem.config.js release/
  artifacts:
    paths:
      - release/
    expire_in: 1 hour

deploy:
  stage: deploy
  image: alpine:latest
  needs: [build]
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  before_script:
    - apk add --no-cache openssh-client rsync bash curl
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan -H "$DROPLET_HOST" >> ~/.ssh/known_hosts 2>/dev/null
  script:
    # 1. Sync build artifacts — .env and node_modules on the server are preserved
    - |
      rsync -avz --delete \
        --exclude='.env' \
        --exclude='node_modules' \
        release/ \
        $DROPLET_USER@$DROPLET_HOST:/var/www/nestapp/

    # 2. SSH in, install deps, reload with zero downtime
    - |
      ssh $DROPLET_USER@$DROPLET_HOST << ENDSSH
        set -e
        cd /var/www/nestapp

        echo "==> Installing production dependencies..."
        HUSKY=0 npm ci --omit=dev --prefer-offline

        echo "==> Reloading app (zero downtime)..."
        pm2 reload ecosystem.config.js --env production --update-env || \
          pm2 start ecosystem.config.js --env production

        echo "==> Saving PM2 process list..."
        pm2 save

        pm2 list
ENDSSH

    # 3. Health check
    - |
      echo "==> Running health check..."
      sleep 10
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://$DROPLET_HOST/ || echo "000")
      if [ "$STATUS" = "200" ] || [ "$STATUS" = "201" ]; then
        echo "Deploy successful! (HTTP $STATUS)"
      else
        echo "Health check failed (HTTP $STATUS) — check pm2 logs"
        exit 1
      fi
  environment:
    name: production
    url: http://$DROPLET_HOST
Enter fullscreen mode Exit fullscreen mode

Part 4 — SSH Key Setup (GitLab ↔ Droplet)

Generate an SSH key pair on your local machine

ssh-keygen -t ed25519 -C "gitlab-deployer" -f ~/.ssh/gitlab_deployer
# Leave passphrase empty — CI/CD can't type a passphrase
Enter fullscreen mode Exit fullscreen mode

This creates two files:

  • ~/.ssh/gitlab_deployer — private key (goes into GitLab)
  • ~/.ssh/gitlab_deployer.pub — public key (goes onto the Droplet)

Add the public key to the Droplet

As root on the Droplet:

> /home/deployer/.ssh/authorized_keys
echo "ssh-ed25519 AAAA...your-public-key... gitlab-deployer" >> /home/deployer/.ssh/authorized_keys

# Permissions are critical — wrong permissions = Permission denied every time
chmod 700 /home/deployer/.ssh
chmod 600 /home/deployer/.ssh/authorized_keys
chown -R deployer:deployer /home/deployer/.ssh
Enter fullscreen mode Exit fullscreen mode

Test it

ssh -i ~/.ssh/gitlab_deployer deployer@your-droplet-ip
Enter fullscreen mode Exit fullscreen mode

Should log in without a password prompt.

Add variables to GitLab

Go to GitLab → Settings → CI/CD → Variables:

Key Value Protected Masked
SSH_PRIVATE_KEY Contents of ~/.ssh/gitlab_deployer
DROPLET_HOST Your Droplet IP
DROPLET_USER deployer
DROPLET_PATH /var/www/nestapp

⚠️ Warning
SSH_PRIVATE_KEY cannot be masked — GitLab doesn't support masking multiline values. Set it as Protected instead so it's only available on protected branches.


Part 5 — First-Time Manual Deploy

Before CI/CD can take over, get the app running on the Droplet manually once.

Build and upload from your local machine

npm run build

scp -r dist package.json package-lock.json ecosystem.config.js root@your-droplet-ip:/var/www/nestapp/

ssh root@your-droplet-ip "chown -R deployer:deployer /var/www/nestapp"
Enter fullscreen mode Exit fullscreen mode

Create the .env file on the Droplet

# As deployer
nano /var/www/nestapp/.env
Enter fullscreen mode Exit fullscreen mode
NODE_ENV=production
PORT=3000
# all your other environment variables...
Enter fullscreen mode Exit fullscreen mode

🚨 Important
Never commit .env to GitLab. This file lives only on the Droplet. The rsync command in the pipeline excludes it so it persists safely across every deployment.

Start PM2

cd /var/www/nestapp
HUSKY=0 npm ci --omit=dev
pm2 start ecosystem.config.js --env production
pm2 save
Enter fullscreen mode Exit fullscreen mode

Configure PM2 to survive reboots

# As deployer
pm2 startup
# Copy and run the generated command as root, then:
pm2 save
Enter fullscreen mode Exit fullscreen mode

Part 6 — Trigger the First Automated Pipeline

git add .gitlab-ci.yml ecosystem.config.js package.json
git commit -m "chore: add CI/CD pipeline and PM2 config"
git push origin main
Enter fullscreen mode Exit fullscreen mode

Go to GitLab → CI/CD → Pipelines and watch all 5 stages:

install → lint → test → build → deploy
Enter fullscreen mode Exit fullscreen mode

Every push to main from this point forward triggers a full pipeline and zero-downtime deployment automatically.


Part 7 — How Zero-Downtime Actually Works

PM2 cluster reload works like this:

  1. GitLab CI builds the new dist/ and rsyncs it to the Droplet
  2. pm2 reload sends a graceful signal to worker 0
  3. Worker 0 finishes all in-flight requests then shuts down
  4. A new worker 0 boots, loads the new code, sends the ready signal
  5. PM2 moves to the next worker and repeats
  6. Nginx keeps routing traffic to all other online workers throughout

No requests are dropped. No downtime.

The wait_ready: true and listen_timeout: 8000 in ecosystem.config.js are what make this reliable — PM2 waits for the process.send('ready') signal from main.ts before it considers the new worker healthy.


Part 8 — Useful Commands

# Check running processes
pm2 list

# View live logs
pm2 logs nestapp

# Monitor CPU and memory
pm2 monit

# Zero-downtime reload (always use this for deployments)
pm2 reload nestapp --update-env

# Hard restart (brief downtime — use only if reload fails)
pm2 restart nestapp --update-env

# Reset restart counter
pm2 reset nestapp

# View last 50 log lines
pm2 logs nestapp --lines 50

# Test Nginx config before reloading
nginx -t

# Reload Nginx without downtime
systemctl reload nginx

# Test the app is responding on the Droplet
curl http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Common Issues and Fixes

Issue Cause Fix
ecosystem.config.js not found File not on Droplet yet Upload manually via scp before first CI run
Permission denied (publickey) Wrong SSH folder permissions chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
EACCES: permission denied mkdir /node_modules SSH heredoc not expanding variables Use unquoted ENDSSH (not 'ENDSSH')
.env deleted on every deploy rsync --delete removes files not in source Add --exclude='.env' and sync from a single release/ folder
husky: not found Husky runs npm ci prepare script on server Prefix with HUSKY=0HUSKY=0 npm ci --omit=dev
App crash-looping Missing .env variables Run pm2 logs nestapp --lines 50 and recreate .env
Cannot find module dist/main.js rsync uploaded folder contents not the folder Use dist without trailing slash in rsync
Node version wrong after install nodesource installs wrong version Install Node directly from nodejs.org binary
PM2 still using old Node version PM2 daemon started with old binary pm2 kill then pm2 start ecosystem.config.js --env production
Nginx Permission denied reading config File permissions too restrictive chmod 644 /etc/nginx/sites-available/nestapp
Certbot ACME challenge fails DNS not pointed to Droplet yet Run dig your-domain.com +short — must return Droplet IP first

Built from a real production deployment of a NestJS Fintech backend. Every issue in the troubleshooting table was hit and solved during the actual setup. NB: The node version used here is old; however, you are free to use the latest version