Most CI/CD tutorials stop at "run your tests on every push." That is the floor, not the ceiling. A real production pipeline catches code style violations before review, builds a versioned Docker image, manages secrets per environment, and ships code to your server — all without you touching a terminal. Here is how to build that, from scratch, using only GitHub Actions and a Linux VPS.


Why GitHub Actions Is Enough for Small SaaS Teams

Enterprise CI/CD platforms like CircleCI or Buildkite earn their place at scale. But for a solo developer or a lean team shipping a Node.js API to customers, GitHub Actions gives you 2,000 free minutes per month on public repos and 500MB of artifact storage. That covers most early-stage products comfortably — and everything lives in the same repository your team already uses.

The goal here is a pipeline with four distinct stages:

  1. Lint — enforce code style before anything else runs
  2. Test — run the full test suite in an isolated environment
  3. Build — package the app into a Docker image and push to a registry
  4. Deploy — pull the new image on your VPS and restart the service

Project Assumptions

  • A Node.js API (Express, Fastify, or similar) with a package.json
  • ESLint configured (.eslintrc.js or equivalent)
  • Jest or Mocha for tests
  • A Dockerfile at the project root
  • A VPS running Ubuntu with Docker and an SSH key pair

Step 1: Structure Your Workflow File

Create .github/workflows/pipeline.yml. GitHub Actions reads any .yml file inside this directory as a workflow.

name: CI/CD Pipeline

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    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 run lint

  test:
    needs: lint
    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
        env:
          NODE_ENV: test
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker stop api || true
            docker rm api || true
            docker run -d --name api \
              -p 3000:3000 \
              -e DATABASE_URL="${{ secrets.PROD_DATABASE_URL }}" \
              -e NODE_ENV=production \
              ghcr.io/${{ github.repository }}:${{ github.sha }}

Step 2: Configure Environment-Specific Secrets

Navigate to Settings → Secrets and variables → Actions in your GitHub repository. Add the following secrets:

  • TEST_DATABASE_URL — a throwaway test database connection string
  • PROD_DATABASE_URL — your production database, never exposed in logs
  • VPS_HOST — the IP address or hostname of your server
  • VPS_USER — typically ubuntu or deploy
  • VPS_SSH_KEY — the private key whose public counterpart is in ~/.ssh/authorized_keys on the VPS

GitHub masks these values in all log output automatically. Never hardcode credentials in the workflow file itself — even in private repositories, it is a habit that bites teams when access is misconfigured.

For teams managing multiple environments (staging, production, preview), use GitHub Environments under the repository settings. Each environment can hold its own secret set and require manual approval before the deploy job runs — a useful guardrail before pushing to production.


Step 3: Keep Docker Images Lean

Your pipeline is only as fast as your Docker build. A bloated image slows the push, slows the pull on the VPS, and widens the attack surface. Two non-negotiable practices:

  • Use a multi-stage Dockerfile. Build dependencies in one stage, copy only the production bundle to a slim node:20-alpine final stage.
  • Add a .dockerignore file that excludes node_modules, .git, test files, and local .env files.

A typical Node.js API image should land under 200MB with these in place.


Step 4: Validate the Pipeline Behaviour

Push a deliberate lint error on a feature branch. The lint job should fail and block the test job — no wasted compute. Merge a clean PR to staging and confirm the build job fires but deploy is skipped (it is scoped to main). Merge to main and watch the full chain execute end to end in the Actions tab.

Use the GitHub Actions workflow visualiser to spot bottlenecks. If test and lint are independent in your setup, run them in parallel by removing the needs: lint dependency from the test job — this shaves seconds off every pull request check.


Common Mistakes to Avoid

  • Running npm install instead of npm cinpm ci uses the lockfile exactly, making builds reproducible.
  • Forgetting docker stop || true — without the fallback, the deploy script fails if no container is running on a fresh server.
  • Storing secrets in environment files committed to the repo — use GitHub Secrets exclusively.
  • Not pinning Action versions — use @v4 tags or commit SHAs, not @latest, to prevent upstream changes from breaking your pipeline silently.

Why This Matters for Your Project

A pipeline like this costs nothing beyond your VPS and eliminates an entire category of deployment anxiety. For SaaS teams shipping features fast — whether in Accra, Lagos, or Nairobi — the ability to merge to main and have tested, containerised code running in production within three minutes is a genuine competitive advantage. You move faster, break less, and spend engineering hours on product, not on manual deployments.