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
mainis linted, built and tested on GitHub Actions (the CI part); - every merge to
maindeploys 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 reloadThe stack:
| Role | Tool |
|---|---|
| API framework | NestJS |
| Runtime and package manager | Node.js LTS and pnpm |
| Process manager | PM2 |
| Reverse proxy and TLS | nginx and Certbot (Let's Encrypt) |
| Webhook receiver | 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 24.04 LTS, with root or sudo access.
- A NestJS project on GitHub, with
build,lint,testandtest:e2escripts inpackage.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 enableOnly 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 pm2Installing 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 buildPut 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 printsCheck 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.comCertbot 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
--runInBandto thetest:e2escript. - 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 worksWhat each part does:
flockskips 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/mainmakes the server matchmainexactly, even after a force-push.pm2 reloadrestarts 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 secretCreate /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.jsonThe 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.targetsudo systemctl daemon-reload
sudo systemctl enable --now webhookThe 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
-
Open a pull request with a small visible change. Wait for the
testcheck to pass, then merge it. -
On the server, follow the deploy as it happens:
journalctl -u webhook -f -
Check the result:
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.