Skip to content
DevOps projects 5 min read

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:

StageTriggerWhat happens
TestEvery PR to mainInstall dependencies, run the test suite
BuildMerge (push) to mainBuild the production app, package it
DeployAfter a successful buildCopy 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 nameValue
SSH_PRIVATE_KEYContents of the deploy_key file (the private one)
SSH_HOSTYour server’s IP or hostname
SSH_USERdeploy

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 with rm 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 }}
ApproachWhen to useWhen NOT to
SSH + rsyncSimple single app on one serverYou need identical environments across machines
Docker imageMultiple services, complex deps, scalingA 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 deploy user with a narrowly scoped sudoers rule — 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.sha so every deploy is traceable and rollback-able.
  • Use npm ci (not npm install) in CI for reproducible, lockfile-exact builds.
  • Pin action versions (@v4) instead of @latest so a third-party update can’t silently break your pipeline.
Last updated June 15, 2026
Was this helpful?