Setup CI/CD for a NestJS API on an Ubuntu VPS

Julien Béranger

+ Claude Opus 5.5

What you'll build

By the end of this tutorial:

  • every pull request to main is linted, built and tested on GitHub Actions (the CI part);
  • every merge to main deploys to your VPS on its own, in a few seconds (the CD part).

The deployment is pull-based. GitHub sends a signed webhook to the server, and the server pulls the code itself. No SSH key or server credential is ever stored in GitHub. If your repository or an Actions runner is compromised, the attacker still can't reach production.

PR ──► GitHub Actions: lint, build, test
merge to main ──► GitHub webhook ──► nginx (HTTPS) ──► webhook listener (127.0.0.1)
                                                        └─► deploy.sh: pull, install, build, pm2 reload

The stack:

RoleTool
API frameworkNestJS
Runtime and package managerNode.js LTS and pnpm
Process managerPM2
Reverse proxy and TLSnginx and Certbot (Let's Encrypt)
Webhook receiveradnanh/webhook

The examples use the placeholders below. Replace them everywhere:

PlaceholderExample
deploythe Linux user that runs the app
my-apithe app and repository name
api.example.comyour domain, with an A record pointing to the VPS
3000the port the NestJS app listens on

Prerequisites

  • A VPS running Ubuntu 24.04 LTS, with root or sudo access.
  • A NestJS project on GitHub, with build, lint, test and test:e2e scripts in package.json (the Nest CLI generates them).
  • A domain name.

1. Prepare the server

Create a non-root user for the app, and turn on the firewall with UFW:

sudo adduser deploy
sudo usermod -aG sudo deploy

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Only ports 22, 80 and 443 are open. The app (port 3000) and the webhook listener (port 9000) stay on 127.0.0.1 and are reached through nginx.

Install Node.js LTS from the NodeSource repository, then pnpm and PM2:

curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs git nginx
sudo npm install -g pnpm pm2

Installing Node system-wide, rather than with nvm, matters later: the deploy script runs from a systemd service that doesn't load your shell profile, so node, pnpm and pm2 must be on the default PATH.

2. Run the app with PM2

As deploy, clone the repository and do a first build:

cd ~
git clone https://github.com/YOUR_ORG/my-api.git
cd my-api
pnpm install --frozen-lockfile
pnpm build

Put secrets in a .env file on the server, never in the repository. Read them with @nestjs/config. Make sure .env is listed in .gitignore.

Create ecosystem.config.js at the root of the repository and commit it. This ecosystem file tells PM2 how to run the app:

module.exports = {
  apps: [
    {
      name: 'my-api',
      script: 'dist/main.js',
      env: { NODE_ENV: 'production', PORT: 3000 },
    },
  ],
};

Start it, and make PM2 come back after a reboot with pm2 startup:

pm2 start ecosystem.config.js
pm2 save
pm2 startup   # then run the sudo command it prints

Check that it answers locally:

curl -i http://127.0.0.1:3000/

Make sure the app listens on 127.0.0.1 or behind the firewall, not on a public interface. In main.ts, await app.listen(process.env.PORT ?? 3000, '127.0.0.1') does that.

3. Put nginx and HTTPS in front

Create /etc/nginx/sites-available/my-api:

server {
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }

    location /hooks/ {
        proxy_pass http://127.0.0.1:9000/hooks/;
    }
}

If your API streams responses with server-sent events, also add proxy_buffering off; to the / block. Otherwise nginx holds the stream back until it ends.

Enable the site and get a certificate. Certbot edits the file to add HTTPS and a redirect from HTTP:

sudo ln -s /etc/nginx/sites-available/my-api /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com

Certbot also installs a timer that renews the certificate automatically. Check https://api.example.com/ from your machine before going further.

4. Continuous integration with GitHub Actions

Create .github/workflows/test.yml in the repository:

name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm build
      - run: pnpm test
      - run: pnpm test:e2e
        env:
          SOME_API_KEY: ${{ secrets.SOME_API_KEY }}

pnpm/action-setup reads the pnpm version from the packageManager field of package.json. Add it with pnpm pkg set packageManager=pnpm@$(pnpm -v) if it's missing.

Store any key the tests need under Settings → Secrets and variables → Actions. These are encrypted secrets for the tests only. They give no access to the server.

Then protect main so nothing reaches it, and therefore production, without passing CI. In Settings → Branches, add a branch protection rule or ruleset for main that requires a pull request and the test status check.

Keep e2e tests deterministic

Two habits avoid most flaky CI runs with NestJS:

  • Run e2e files one at a time if they share any state on disk or in a database. Jest runs test files in parallel worker processes by default, and in-process locks don't protect anything across processes. Add --runInBand to the test:e2e script.
  • Mock outside services. Tests that call real third-party APIs fail when those APIs are slow, rate-limited or down. Override the providers in the testing module instead.

5. The deploy script

Create the script on the server:

sudo mkdir -p /opt/deploy && sudo chown deploy:deploy /opt/deploy

/opt/deploy/deploy-my-api.sh:

#!/usr/bin/env bash
set -euo pipefail

exec 9>/tmp/deploy-my-api.lock
flock -n 9 || { echo "deploy already running, skipping"; exit 0; }

cd /home/deploy/my-api

if [ -n "$(git status --porcelain)" ]; then
  echo "working tree dirty, aborting"
  git status --porcelain
  exit 1
fi

git fetch origin main
git reset --hard origin/main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --update-env

echo "deployed $(git rev-parse --short HEAD)"
chmod +x /opt/deploy/deploy-my-api.sh
/opt/deploy/deploy-my-api.sh   # run it once by hand to check it works

What each part does:

  • flock skips a deploy if one is already running, for example when two merges land close together.
  • The dirty-tree check stops the deploy instead of silently throwing away changes someone made on the server. Never let the app write to files tracked by git (logs, caches, JSON stores). Write runtime data to git-ignored paths. Otherwise the first request after a deploy makes the tree dirty, and every later deploy is skipped.
  • git reset --hard origin/main makes the server match main exactly, even after a force-push.
  • pm2 reload restarts the app. In cluster mode it does so with zero downtime.

6. The webhook listener

Install webhook, a small Go server that runs a command when it receives a matching HTTP request:

sudo apt install -y webhook
openssl rand -hex 32   # keep this value: it is the webhook secret

Create /home/deploy/hooks.json, replacing REPLACE_ME with the secret:

[
  {
    "id": "deploy-my-api",
    "execute-command": "/opt/deploy/deploy-my-api.sh",
    "command-working-directory": "/home/deploy/my-api",
    "response-message": "deploying",
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "payload-hmac-sha256",
            "secret": "REPLACE_ME",
            "parameter": { "source": "header", "name": "X-Hub-Signature-256" }
          }
        },
        {
          "match": {
            "type": "value",
            "value": "refs/heads/main",
            "parameter": { "source": "payload", "name": "ref" }
          }
        }
      ]
    }
  }
]
chmod 600 /home/deploy/hooks.json

The first rule checks the HMAC signature GitHub adds to each delivery, so only GitHub can trigger a deploy. The second rule ignores pushes to any branch other than main.

Run it as a systemd service. Create /etc/systemd/system/webhook.service:

[Unit]
Description=GitHub webhook listener
After=network.target

[Service]
User=deploy
ExecStart=/usr/bin/webhook -hooks /home/deploy/hooks.json -ip 127.0.0.1 -port 9000 -verbose
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now webhook

The listener binds to 127.0.0.1 only. The internet reaches it through the /hooks/ location in nginx, over HTTPS.

7. Connect GitHub to the server

In the repository, go to Settings → Webhooks → Add webhook:

  • Payload URL: https://api.example.com/hooks/deploy-my-api
  • Content type: application/json
  • Secret: the value you generated in step 6
  • Events: just the push event

When you save, GitHub sends a ping event. It's rejected by the ref rule, which is expected. The Recent Deliveries tab shows every request and the response. See validating webhook deliveries for details on the signature.

8. Test the full loop

  1. Open a pull request with a small visible change. Wait for the test check to pass, then merge it.

  2. On the server, follow the deploy as it happens:

    journalctl -u webhook -f
  3. Check the result:

    pm2 status
    pm2 logs my-api --lines 50
    curl -i https://api.example.com/

Troubleshooting

SymptomWhere to lookLikely cause
GitHub delivery shows a 502systemctl status webhookthe listener isn't running or isn't on port 9000
Delivery returns "Hook rules were not satisfied"hooks.jsonwrong secret, wrong content type, or a push to another branch
Hook fires but nothing is deployedjournalctl -u webhookpnpm or pm2 not on the service's PATH, or a permissions problem
Log says working tree dirty, abortinggit status in the app folderthe app writes to a tracked file, or someone edited files on the server
CI fails only sometimesthe failing test's outpute2e files running in parallel on shared state, or real network calls

To roll back, revert the bad commit on GitHub and merge the revert. It deploys like any other change, and the history stays clean.

Further reading