Project: Build a CI/CD Pipeline
In this project you build a complete CI/CD pipeline from scratch. CI/CD stands for Continuous Integration (automatically testing your code every time you push) and Continuous Deployment (automatically shipping it to a server when the tests pass). By the end you will have a pipeline that runs your tests on every Pull Request, builds the app when code is merged to main, and deploys it to an Ubuntu server over SSH — all without you touching the server by hand. This is the capstone that ties together everything you learned in the CI/CD pages.
What we are building
We will use GitHub Actions — GitHub’s built-in automation system that runs scripts (called workflows) in response to events like a push or a Pull Request. A Pull Request (PR) is a proposed change that someone wants to merge into the main code. Our plan has three stages:
| Stage | Trigger | What happens |
|---|---|---|
| Test | Every PR to main | Install dependencies, run the test suite |
| Build | Merge (push) to main | Build the production app, package it |
| Deploy | After a successful build | Copy files to the server over SSH, restart the app |
Never run deployment steps on a PR from a fork. A fork is a copy of your repo owned by someone else — if your deploy secrets ran on their code, they could steal your server credentials. We guard deploy with
if: github.ref == 'refs/heads/main'so it only runs on real merges.
Prerequisites
You need a GitHub repository with a Node.js app (the same idea works for any stack), an Ubuntu 22.04/24.04 server you can SSH into, and a package.json with test and build scripts. SSH (Secure Shell) is the encrypted protocol you use to log into a remote server’s command line.
Step 1 — Prepare the server
On your Ubuntu server, create a dedicated deploy user so the pipeline never logs in as root (the all-powerful admin account). Running deploys as root is a security risk because a mistake or a leaked key gives an attacker total control.
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chown -R deploy:deploy /home/deploy/.ssh
We will run the app with systemd, the standard Ubuntu service manager that keeps your app running and restarts it if it crashes. Create the service file:
# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
[Service]
User=deploy
WorkingDirectory=/home/deploy/app
ExecStart=/usr/bin/node /home/deploy/app/dist/server.js
Restart=always
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
Enable it so it starts on boot and allow the deploy user to restart it without a password:
sudo systemctl daemon-reload
sudo systemctl enable myapp
echo "deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp" | sudo tee /etc/sudoers.d/deploy
Step 2 — Create an SSH deploy key
A deploy key is a dedicated SSH key pair used only by the pipeline. On your local machine, generate one:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key -N ""
Output:
Generating public/private ed25519 key pair.
Your identification has been saved in ./deploy_key
Your public key has been saved in ./deploy_key.pub
Add the public key to the server so the pipeline is allowed in:
cat deploy_key.pub | ssh deploy@YOUR_SERVER_IP "cat >> /home/deploy/.ssh/authorized_keys"
Step 3 — Store secrets in GitHub
Secrets are encrypted values GitHub injects into your workflow at runtime so they never appear in your code. In your repo, go to Settings → Secrets and variables → Actions → New repository secret and add:
| Secret name | Value |
|---|---|
SSH_PRIVATE_KEY | Contents of the deploy_key file (the private one) |
SSH_HOST | Your server’s IP or hostname |
SSH_USER | deploy |
The private key (
deploy_key, no.pub) must NEVER be committed to git. Anyone with it can log into your server. Paste it into the GitHub secret, then delete the local file withrm deploy_key.
Step 4 — The test workflow
Workflows live in .github/workflows/ as YAML files. This first one runs tests on every PR:
# .github/workflows/test.yml
name: Test
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm test
npm ci does a clean, reproducible install from the lockfile — use it in CI instead of npm install because it is faster and never silently changes your dependency versions.
Step 5 — The build-and-deploy workflow
This one runs only when code lands on main. It builds the app, then copies the output to the server and restarts the service.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
- name: Set up SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
- name: Copy build to server
run: |
rsync -az --delete ./dist/ \
"${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/deploy/app/dist/"
- name: Restart service
run: |
ssh "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
"sudo systemctl restart myapp"
rsync -az --delete copies only changed files (fast) and removes stale files on the server so old code never lingers. ssh-keyscan records the server’s fingerprint so the connection isn’t blocked by an unknown-host prompt.
Push to main and watch the Actions tab. A successful run looks like:
Output:
Run ssh [email protected] "sudo systemctl restart myapp"
Restart Complete.
Job completed in 41s ✓
Alternative: build and push a Docker image
If you prefer containers, replace the rsync/restart steps with a Docker build that pushes to a registry. The server then pulls the new image. Use this when your app has many system dependencies or you run multiple services.
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- run: |
docker build -t myuser/myapp:${{ github.sha }} .
docker push myuser/myapp:${{ github.sha }}
| Approach | When to use | When NOT to |
|---|---|---|
| SSH + rsync | Simple single app on one server | You need identical environments across machines |
| Docker image | Multiple services, complex deps, scaling | A tiny static site — it adds overhead |
Best Practices
- Run tests on PRs and require them to pass before merge (enable branch protection in repo settings).
- Use a dedicated non-root
deployuser with a narrowly scopedsudoersrule — never deploy as root. - Keep every credential in GitHub Secrets; never hardcode keys, hosts, or tokens in YAML.
- Guard deploy jobs with
if: github.ref == 'refs/heads/main'so they never run on forked PRs. - Tag Docker images or build artifacts with
github.shaso every deploy is traceable and rollback-able. - Use
npm ci(notnpm install) in CI for reproducible, lockfile-exact builds. - Pin action versions (
@v4) instead of@latestso a third-party update can’t silently break your pipeline.