---
title: Setup CI/CD for a NestJS API on an Ubuntu VPS
description: A step-by-step tutorial to test a NestJS API on GitHub Actions and deploy it automatically to an Ubuntu VPS on every merge to main, with no SSH key stored in GitHub.
date: 2026-10-01
lang: en-US
author: Julien Béranger
model: Claude Opus 5.5
source: https://julienberanger.com/cicd-nestjs-vps
---

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

## What you'll build

By the end of this tutorial:

- every pull request to `main` is linted, built and tested on [GitHub Actions](https://docs.github.com/en/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](https://github.com/) sends a signed [webhook](https://docs.github.com/en/webhooks) 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:

| Role | Tool |
| --- | --- |
| API framework | [NestJS](https://nestjs.com/) |
| Runtime and package manager | [Node.js](https://nodejs.org/) LTS and [pnpm](https://pnpm.io/) |
| Process manager | [PM2](https://pm2.keymetrics.io/) |
| Reverse proxy and TLS | [nginx](https://nginx.org/) and [Certbot](https://certbot.eff.org/) ([Let's Encrypt](https://letsencrypt.org/)) |
| Webhook receiver | [adnanh/webhook](https://github.com/adnanh/webhook) |

The examples use the placeholders below. Replace them everywhere:

| Placeholder | Example |
| --- | --- |
| `deploy` | the Linux user that runs the app |
| `my-api` | the app and repository name |
| `api.example.com` | your domain, with an A record pointing to the VPS |
| `3000` | the port the NestJS app listens on |

## Prerequisites

- A VPS running [Ubuntu](https://ubuntu.com/server) 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](https://docs.nestjs.com/cli/overview) generates them).
- A domain name.

## 1. Prepare the server

Create a non-root user for the app, and turn on the firewall with [UFW](https://help.ubuntu.com/community/UFW):

```bash
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](https://github.com/nodesource/distributions), then pnpm and PM2:

```bash
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](https://github.com/nvm-sh/nvm), matters later: the deploy script runs from a [systemd](https://systemd.io/) 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:

```bash
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`](https://docs.nestjs.com/techniques/configuration). Make sure `.env` is listed in `.gitignore`.

Create `ecosystem.config.js` at the root of the repository and commit it. This [ecosystem file](https://pm2.keymetrics.io/docs/usage/application-declaration/) tells PM2 how to run the app:

```js
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`](https://pm2.keymetrics.io/docs/usage/startup/):

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

Check that it answers locally:

```bash
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`:

```nginx
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](https://developer.mozilla.org/en-US/docs/Web/API/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:

```bash
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:

```yaml
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`](https://github.com/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](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) 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](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) 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](https://jestjs.io/) runs test files in parallel worker processes by default, and in-process locks don't protect anything across processes. Add [`--runInBand`](https://jestjs.io/docs/cli#--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](https://docs.nestjs.com/fundamentals/testing) instead.

## 5. The deploy script

Create the script on the server:

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

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

```bash
#!/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)"
```

```bash
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`](https://man7.org/linux/man-pages/man1/flock.1.html) 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`](https://pm2.keymetrics.io/docs/usage/cluster-mode/#reload) restarts the app. In cluster mode it does so with zero downtime.

## 6. The webhook listener

Install [webhook](https://github.com/adnanh/webhook), a small Go server that runs a command when it receives a matching HTTP request:

```bash
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:

```json
[
  {
    "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" }
          }
        }
      ]
    }
  }
]
```

```bash
chmod 600 /home/deploy/hooks.json
```

The first rule checks the [HMAC](https://en.wikipedia.org/wiki/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`:

```ini
[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
```

```bash
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](https://docs.github.com/en/webhooks/using-webhooks/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:

   ```bash
   journalctl -u webhook -f
   ```

3. Check the result:

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

## Troubleshooting

| Symptom | Where to look | Likely cause |
| --- | --- | --- |
| GitHub delivery shows a 502 | `systemctl status webhook` | the listener isn't running or isn't on port 9000 |
| Delivery returns "Hook rules were not satisfied" | `hooks.json` | wrong secret, wrong content type, or a push to another branch |
| Hook fires but nothing is deployed | `journalctl -u webhook` | `pnpm` or `pm2` not on the service's `PATH`, or a permissions problem |
| Log says `working tree dirty, aborting` | `git status` in the app folder | the app writes to a tracked file, or someone edited files on the server |
| CI fails only sometimes | the failing test's output | e2e 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

- [NestJS deployment guide](https://docs.nestjs.com/deployment)
- [PM2 quick start](https://pm2.keymetrics.io/docs/usage/quick-start/)
- [webhook hook rules](https://github.com/adnanh/webhook/blob/master/docs/Hook-Rules.md)
- [GitHub Actions: building and testing Node.js](https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-nodejs)
- [nginx reverse proxy guide](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/)
