{
"$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"
}