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:
- Lint — enforce code style before anything else runs
- Test — run the full test suite in an isolated environment
- Build — package the app into a Docker image and push to a registry
- 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.jsor 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 stringPROD_DATABASE_URL— your production database, never exposed in logsVPS_HOST— the IP address or hostname of your serverVPS_USER— typicallyubuntuordeployVPS_SSH_KEY— the private key whose public counterpart is in~/.ssh/authorized_keyson 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-alpinefinal stage. - Add a
.dockerignorefile that excludesnode_modules,.git, test files, and local.envfiles.
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 installinstead ofnpm ci—npm ciuses 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
@v4tags 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.





