{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreidffl4attsh45pcscbe25bltzfkfhksffyh3q25xtocohpar2qm6a",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpr4vbin2uv2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreia4mfaftu7zzt5dvmxh3okdt4ihouqpem3s7idvpgdrv4km2qiae4"
    },
    "mimeType": "image/webp",
    "size": 55204
  },
  "path": "/fecoded/nestjs-zero-downtime-deployment-on-digitalocean-with-gitlab-cicd-and-pm2-fo7",
  "publishedAt": "2026-07-03T17:13:00.000Z",
  "site": "https://dev.to",
  "tags": [
    "nestjs",
    "devops",
    "gitlab",
    "digitalocean",
    "@nestjs"
  ],
  "textContent": "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.\n\n**Stack:**\n\n  * **Server:** DigitalOcean Droplet (Ubuntu 24.04)\n  * **Runtime:** Node.js v18\n  * **Process Manager:** PM2 (cluster mode)\n  * **Reverse Proxy:** Nginx\n  * **CI/CD:** GitLab CI/CD\n  * **Deploy user:** `deployer` (non-root, for security)\n\n\n\n##  Part 1 — Droplet Initial Setup\n\nSSH into your Droplet as root and run the following steps once.\n\n###  Install Node.js v18\n\n> ⚠️ **Warning**\n>  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.\n>\n\n\n    # Remove any existing Node.js installation\n    apt-get purge -y nodejs npm\n    apt-get autoremove -y\n    apt-get autoclean\n    rm -f /etc/apt/sources.list.d/nodesource.list\n    rm -f /etc/apt/sources.list.d/node*.list\n    rm -f /etc/apt/keyrings/nodesource.gpg\n    rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx\n    apt-get update\n\n    # Download and install Node.js v18 directly from nodejs.org\n    cd /tmp\n    curl -fsSL https://nodejs.org/dist/v18.20.8/node-v18.20.8-linux-x64.tar.xz -o node18.tar.xz\n    tar -xJf node18.tar.xz\n    cp -r node-v18.20.8-linux-x64/bin/* /usr/local/bin/\n    cp -r node-v18.20.8-linux-x64/lib/* /usr/local/lib/\n    cp -r node-v18.20.8-linux-x64/include/* /usr/local/include/\n\n    # Verify\n    node -v   # v18.20.8\n    npm -v\n\n\n###  Install PM2 and Nginx\n\n\n    npm install -g pm2\n    apt-get install -y nginx\n\n\n###  Create a non-root deploy user\n\nNever deploy as root. Create a dedicated `deployer` user:\n\n\n\n    useradd -m -s /bin/bash deployer\n\n    # Create app directory\n    mkdir -p /var/www/nestapp\n    chown -R deployer:deployer /var/www/nestapp\n\n    # Create PM2 log directory\n    mkdir -p /var/log/pm2\n    chown -R deployer:deployer /var/log/pm2\n\n\n###  Set up SSH for the deployer user\n\n\n    mkdir -p /home/deployer/.ssh\n    chmod 700 /home/deployer/.ssh\n    touch /home/deployer/.ssh/authorized_keys\n    chmod 600 /home/deployer/.ssh/authorized_keys\n    chown -R deployer:deployer /home/deployer/.ssh\n\n\n###  Configure the firewall\n\n\n    ufw allow OpenSSH\n    ufw allow 'Nginx Full'\n    ufw --force enable\n\n\n##  Part 2 — Nginx Configuration\n\n###  Create the Nginx site config\n\n\n    nano /etc/nginx/sites-available/nestapp\n\n\nPaste the following, replacing `your-domain.com` with your actual domain or Droplet IP:\n\n\n\n    upstream nestapp {\n        server 127.0.0.1:3000;\n        keepalive 64;\n    }\n\n    server {\n        listen 80;\n        listen [::]:80;\n        server_name your-domain.com;\n\n        location / {\n            proxy_pass http://nestapp;\n            proxy_http_version 1.1;\n            proxy_set_header Host              $host;\n            proxy_set_header X-Real-IP         $remote_addr;\n            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header Upgrade           $http_upgrade;\n            proxy_set_header Connection        'upgrade';\n            proxy_cache_bypass $http_upgrade;\n            proxy_read_timeout 240s;\n            proxy_connect_timeout 10s;\n        }\n\n        location /health {\n            proxy_pass http://nestapp;\n            access_log off;\n        }\n    }\n\n\n###  Enable the site\n\n\n    # Symlink to enable\n    ln -s /etc/nginx/sites-available/nestapp /etc/nginx/sites-enabled/nestapp\n\n    # Remove default site\n    rm /etc/nginx/sites-enabled/default\n\n    # Fix file permissions (important — Nginx can't read files owned only by root)\n    chmod 644 /etc/nginx/sites-available/nestapp\n\n    # Test config\n    nginx -t\n\n    # Reload Nginx\n    systemctl reload nginx\n\n\n###  SSL with Certbot\n\nOnce your domain DNS is pointing to the Droplet:\n\n\n\n    apt install certbot python3-certbot-nginx -y\n    certbot --nginx -d your-domain.com\n\n\n> ℹ️ **Note**\n>  Make sure `dig your-domain.com +short` returns your Droplet IP before running certbot, otherwise the ACME challenge will fail.\n\n##  Part 3 — Project File Setup\n\n###  ecosystem.config.js\n\nAdd this to your project root and commit it to GitLab:\n\n\n\n    module.exports = {\n      apps: [\n        {\n          name: 'nestapp',\n          script: 'dist/main.js',\n          instances: 'max',\n          exec_mode: 'cluster',\n          node_args: '--security-revert=CVE-2023-46809', // remove if not needed\n          autorestart: true,\n          watch: false,\n          max_memory_restart: '512M',\n          env_production: {\n            NODE_ENV: 'production',\n            PORT: 3000,\n          },\n          out_file: '/var/log/pm2/nestapp-out.log',\n          error_file: '/var/log/pm2/nestapp-error.log',\n          log_date_format: 'YYYY-MM-DD HH:mm:ss Z',\n          merge_logs: true,\n          kill_timeout: 5000,\n          listen_timeout: 8000,\n          wait_ready: true,\n        },\n      ],\n    };\n\n\n###  Update src/main.ts\n\nSignal PM2 when each worker is ready — this is what makes zero-downtime reload actually work:\n\n\n\n    import { NestFactory } from '@nestjs/core';\n    import { AppModule } from './app.module';\n\n    async function bootstrap() {\n      const app = await NestFactory.create(AppModule, { bufferLogs: true });\n\n      await app.listen(process.env.PORT || 3000);\n\n      // Tell PM2 this worker is ready to receive traffic\n      if (process.send) {\n        process.send('ready');\n      }\n    }\n    bootstrap();\n\n\n###  Update package.json — disable Husky in production\n\nWithout this, `npm ci` on the server will fail because Husky tries to install git hooks:\n\n\n\n    \"scripts\": {\n      \"prepare\": \"husky install || true\"\n    }\n\n\n###  .gitlab-ci.yml\n\nAdd this to your project root:\n\n\n\n    image: node:18-alpine\n\n    stages:\n      - install\n      - lint\n      - test\n      - build\n      - deploy\n\n    cache:\n      key:\n        files:\n          - package-lock.json\n      paths:\n        - node_modules/\n      policy: pull-push\n\n    workflow:\n      rules:\n        - if: $CI_COMMIT_BRANCH == \"main\"\n        - if: $CI_MERGE_REQUEST_IID\n\n    variables:\n      NODE_ENV: test\n\n    install:\n      stage: install\n      script:\n        - npm ci --prefer-offline\n      artifacts:\n        paths:\n          - node_modules/\n        expire_in: 1 hour\n\n    lint:\n      stage: lint\n      needs: [install]\n      script:\n        - npm run lint\n\n    test:\n      stage: test\n      needs: [install]\n      script:\n        - npm run test -- --passWithNoTests\n\n    build:\n      stage: build\n      needs: [lint, test]\n      script:\n        - npm run build\n        - mkdir -p release\n        - cp -r dist package.json package-lock.json ecosystem.config.js release/\n      artifacts:\n        paths:\n          - release/\n        expire_in: 1 hour\n\n    deploy:\n      stage: deploy\n      image: alpine:latest\n      needs: [build]\n      rules:\n        - if: $CI_COMMIT_BRANCH == \"main\"\n      before_script:\n        - apk add --no-cache openssh-client rsync bash curl\n        - eval $(ssh-agent -s)\n        - echo \"$SSH_PRIVATE_KEY\" | tr -d '\\r' | ssh-add -\n        - mkdir -p ~/.ssh && chmod 700 ~/.ssh\n        - ssh-keyscan -H \"$DROPLET_HOST\" >> ~/.ssh/known_hosts 2>/dev/null\n      script:\n        # 1. Sync build artifacts — .env and node_modules on the server are preserved\n        - |\n          rsync -avz --delete \\\n            --exclude='.env' \\\n            --exclude='node_modules' \\\n            release/ \\\n            $DROPLET_USER@$DROPLET_HOST:/var/www/nestapp/\n\n        # 2. SSH in, install deps, reload with zero downtime\n        - |\n          ssh $DROPLET_USER@$DROPLET_HOST << ENDSSH\n            set -e\n            cd /var/www/nestapp\n\n            echo \"==> Installing production dependencies...\"\n            HUSKY=0 npm ci --omit=dev --prefer-offline\n\n            echo \"==> Reloading app (zero downtime)...\"\n            pm2 reload ecosystem.config.js --env production --update-env || \\\n              pm2 start ecosystem.config.js --env production\n\n            echo \"==> Saving PM2 process list...\"\n            pm2 save\n\n            pm2 list\n    ENDSSH\n\n        # 3. Health check\n        - |\n          echo \"==> Running health check...\"\n          sleep 10\n          STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" http://$DROPLET_HOST/ || echo \"000\")\n          if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"201\" ]; then\n            echo \"Deploy successful! (HTTP $STATUS)\"\n          else\n            echo \"Health check failed (HTTP $STATUS) — check pm2 logs\"\n            exit 1\n          fi\n      environment:\n        name: production\n        url: http://$DROPLET_HOST\n\n\n##  Part 4 — SSH Key Setup (GitLab ↔ Droplet)\n\n###  Generate an SSH key pair on your local machine\n\n\n    ssh-keygen -t ed25519 -C \"gitlab-deployer\" -f ~/.ssh/gitlab_deployer\n    # Leave passphrase empty — CI/CD can't type a passphrase\n\n\nThis creates two files:\n\n  * `~/.ssh/gitlab_deployer` — private key (goes into GitLab)\n  * `~/.ssh/gitlab_deployer.pub` — public key (goes onto the Droplet)\n\n\n\n###  Add the public key to the Droplet\n\nAs **root** on the Droplet:\n\n\n\n    > /home/deployer/.ssh/authorized_keys\n    echo \"ssh-ed25519 AAAA...your-public-key... gitlab-deployer\" >> /home/deployer/.ssh/authorized_keys\n\n    # Permissions are critical — wrong permissions = Permission denied every time\n    chmod 700 /home/deployer/.ssh\n    chmod 600 /home/deployer/.ssh/authorized_keys\n    chown -R deployer:deployer /home/deployer/.ssh\n\n\n###  Test it\n\n\n    ssh -i ~/.ssh/gitlab_deployer deployer@your-droplet-ip\n\n\nShould log in without a password prompt.\n\n###  Add variables to GitLab\n\nGo to **GitLab → Settings → CI/CD → Variables** :\n\nKey | Value | Protected | Masked\n---|---|---|---\n`SSH_PRIVATE_KEY` | Contents of `~/.ssh/gitlab_deployer` | ✅ | ❌\n`DROPLET_HOST` | Your Droplet IP | ✅ | ❌\n`DROPLET_USER` | `deployer` | ✅ | ❌\n`DROPLET_PATH` | `/var/www/nestapp` | ✅ | ❌\n\n> ⚠️ **Warning**\n>  `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.\n\n##  Part 5 — First-Time Manual Deploy\n\nBefore CI/CD can take over, get the app running on the Droplet manually once.\n\n###  Build and upload from your local machine\n\n\n    npm run build\n\n    scp -r dist package.json package-lock.json ecosystem.config.js root@your-droplet-ip:/var/www/nestapp/\n\n    ssh root@your-droplet-ip \"chown -R deployer:deployer /var/www/nestapp\"\n\n\n###  Create the .env file on the Droplet\n\n\n    # As deployer\n    nano /var/www/nestapp/.env\n\n\n\n    NODE_ENV=production\n    PORT=3000\n    # all your other environment variables...\n\n\n> 🚨 **Important**\n>  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.\n\n###  Start PM2\n\n\n    cd /var/www/nestapp\n    HUSKY=0 npm ci --omit=dev\n    pm2 start ecosystem.config.js --env production\n    pm2 save\n\n\n###  Configure PM2 to survive reboots\n\n\n    # As deployer\n    pm2 startup\n    # Copy and run the generated command as root, then:\n    pm2 save\n\n\n##  Part 6 — Trigger the First Automated Pipeline\n\n\n    git add .gitlab-ci.yml ecosystem.config.js package.json\n    git commit -m \"chore: add CI/CD pipeline and PM2 config\"\n    git push origin main\n\n\nGo to **GitLab → CI/CD → Pipelines** and watch all 5 stages:\n\n\n\n    install → lint → test → build → deploy\n\n\nEvery push to `main` from this point forward triggers a full pipeline and zero-downtime deployment automatically.\n\n##  Part 7 — How Zero-Downtime Actually Works\n\nPM2 cluster reload works like this:\n\n  1. GitLab CI builds the new `dist/` and rsyncs it to the Droplet\n  2. `pm2 reload` sends a graceful signal to worker 0\n  3. Worker 0 finishes all in-flight requests then shuts down\n  4. A new worker 0 boots, loads the new code, sends the `ready` signal\n  5. PM2 moves to the next worker and repeats\n  6. Nginx keeps routing traffic to all other online workers throughout\n\n\n\nNo requests are dropped. No downtime.\n\nThe `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.\n\n##  Part 8 — Useful Commands\n\n\n    # Check running processes\n    pm2 list\n\n    # View live logs\n    pm2 logs nestapp\n\n    # Monitor CPU and memory\n    pm2 monit\n\n    # Zero-downtime reload (always use this for deployments)\n    pm2 reload nestapp --update-env\n\n    # Hard restart (brief downtime — use only if reload fails)\n    pm2 restart nestapp --update-env\n\n    # Reset restart counter\n    pm2 reset nestapp\n\n    # View last 50 log lines\n    pm2 logs nestapp --lines 50\n\n    # Test Nginx config before reloading\n    nginx -t\n\n    # Reload Nginx without downtime\n    systemctl reload nginx\n\n    # Test the app is responding on the Droplet\n    curl http://localhost:3000\n\n\n##  Common Issues and Fixes\n\nIssue | Cause | Fix\n---|---|---\n`ecosystem.config.js not found` | File not on Droplet yet | Upload manually via `scp` before first CI run\n`Permission denied (publickey)` | Wrong SSH folder permissions | `chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys`\n`EACCES: permission denied mkdir /node_modules` | SSH heredoc not expanding variables | Use unquoted `ENDSSH` (not `'ENDSSH'`)\n`.env deleted on every deploy` | rsync `--delete` removes files not in source | Add `--exclude='.env'` and sync from a single `release/` folder\n`husky: not found` | Husky runs `npm ci` prepare script on server | Prefix with `HUSKY=0` → `HUSKY=0 npm ci --omit=dev`\nApp crash-looping | Missing `.env` variables | Run `pm2 logs nestapp --lines 50` and recreate `.env`\n`Cannot find module dist/main.js` | rsync uploaded folder contents not the folder | Use `dist` without trailing slash in rsync\nNode version wrong after install | nodesource installs wrong version | Install Node directly from `nodejs.org` binary\nPM2 still using old Node version | PM2 daemon started with old binary |  `pm2 kill` then `pm2 start ecosystem.config.js --env production`\nNginx `Permission denied` reading config | File permissions too restrictive | `chmod 644 /etc/nginx/sites-available/nestapp`\nCertbot ACME challenge fails | DNS not pointed to Droplet yet | Run `dig your-domain.com +short` — must return Droplet IP first\n\n_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_",
  "title": "NestJS Zero-Downtime Deployment on DigitalOcean with GitLab CI/CD and PM2"
}