CI/CD Pipelines with GitHub Actions: From “Push” to Deploy (with Tests, Caching, and Safe Releases)

CI/CD Pipelines with GitHub Actions: From “Push” to Deploy (with Tests, Caching, and Safe Releases)

A CI/CD pipeline turns a code push into a repeatable process: install dependencies, run checks, build artifacts, and (optionally) deploy. For junior/mid developers, the biggest win is consistency—no “works on my machine,” fewer broken releases, and faster feedback.

This guide builds a practical GitHub Actions pipeline that:

  • Runs lint + unit tests on every pull request
  • Builds artifacts on merge to main
  • Builds and pushes a Docker image (optional but common)
  • Deploys safely to a server via SSH (simple and effective)

We’ll assume a typical web repo layout:

  • frontend/ (React/Vite/Next—any Node build)
  • api/ (Node API, or any service you can test/build)

1) Add fast local scripts first (CI should run what you run)

CI is easiest when it just runs your existing scripts. In your package.json (frontend and/or api), ensure you have these basics:

{ "scripts": { "lint": "eslint .", "test": "vitest run", "build": "vite build" } }

If you’re using Jest instead of Vitest, swap vitest run for jest. The point is: CI runs npm run lint, npm test, npm run build.

2) Create a CI workflow for PRs: lint + tests with caching

Create .github/workflows/ci.yml. This workflow runs on pull requests and on pushes to main. It uses dependency caching (built into actions/setup-node) to speed things up.

name: CI on: pull_request: push: branches: [ "main" ] jobs: test: runs-on: ubuntu-latest strategy: matrix: node: [18, 20] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: npm cache-dependency-path: | frontend/package-lock.json api/package-lock.json - name: Install dependencies (frontend) working-directory: frontend run: npm ci - name: Lint (frontend) working-directory: frontend run: npm run lint - name: Test (frontend) working-directory: frontend run: npm test - name: Install dependencies (api) working-directory: api run: npm ci - name: Lint (api) working-directory: api run: npm run lint - name: Test (api) working-directory: api run: npm test

Why this matters:

  • npm ci is deterministic and faster for CI than npm install
  • Matrix testing catches “works on Node 18 but not 20” problems early
  • Caching reduces pipeline time significantly once lockfiles stabilize

3) Add a build job that only runs when tests pass

Now add a build job that depends on test. This job can run only on main pushes (merges), so you don’t waste cycles building every PR unless you want to.

 build: needs: test 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 cache-dependency-path: | frontend/package-lock.json api/package-lock.json - name: Build frontend working-directory: frontend run: | npm ci npm run build - name: Build api working-directory: api run: | npm ci npm run build

At this point you have a solid CI pipeline: PRs get checked, merges get built.

4) Optional: Build and push a Docker image (tagged by commit)

If you deploy with containers, add an image build step. You’ll typically tag images using the Git commit SHA so every deployment is traceable.

Create a Dockerfile at repo root (example for a Node API; adjust as needed):

# Dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY api/package*.json ./api/ WORKDIR /app/api RUN npm ci FROM node:20-alpine AS runner WORKDIR /app/api COPY --from=deps /app/api/node_modules ./node_modules COPY api/ ./ ENV NODE_ENV=production EXPOSE 3000 CMD ["node", "dist/index.js"]

Now add a job step to build and push to GitHub Container Registry (ghcr.io). Add this into the build job or create a separate docker job with needs: build.

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

Notes:

  • permissions.packages: write is required to push to GHCR
  • Tagging by ${{ github.sha }} gives you an immutable version
  • latest is convenient, but deployments should prefer SHA tags for rollbacks

5) Simple CD: Deploy over SSH (safe enough for many teams)

For many small/medium projects, a straightforward CD step is “SSH into the server and restart the service.” It’s not fancy, but it’s practical. You’ll need:

  • A server reachable by SSH
  • A non-root deploy user
  • Secrets in GitHub: SSH_HOST, SSH_USER, SSH_KEY

Add a deploy job that runs after the Docker push:

 deploy: needs: docker runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - name: Deploy via SSH uses: appleboy/[email protected] with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} script: | set -e IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}" docker login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" docker pull "$IMAGE" docker stop myapp || true docker rm myapp || true docker run -d \ --name myapp \ --restart unless-stopped \ -p 3000:3000 \ -e NODE_ENV=production \ "$IMAGE"

What makes this safer than “just restart”?

  • set -e fails fast if any command fails
  • Deploys a specific immutable image (SHA tag)
  • Reproducible: the server pulls the same artifact CI built

6) Add “release gates” that prevent bad merges

Two practical gates for junior/mid teams:

  • Branch protection: require CI checks to pass before merging to main
  • Required reviews: at least one reviewer approval for PRs

These live in GitHub repository settings (not code), but they’re the difference between “CI exists” and “CI prevents incidents.”

7) Debugging CI failures quickly (a checklist)

  • Reproduce locally: run the exact command CI runs (npm ci, npm test)
  • Check Node version: CI might use Node 20 while you’re on 18
  • Lockfile drift: CI uses npm ci; ensure package-lock.json is committed
  • Environment variables: tests that rely on env vars should set defaults or use CI secrets
  • Flaky tests: add retries sparingly; prefer fixing timing and isolation issues

8) A minimal “copy/paste” pipeline you can adapt

If you want the simplest shape for most web repos:

  • PR: lint + test
  • Main: build + docker push + deploy

That’s exactly what the workflows above implement—with just enough structure to scale (matrix testing, caching, job dependencies, immutable deploy tags).

Once this is running, the next upgrades are easy: preview deployments for PRs, running database migrations safely, adding end-to-end tests, and notifying Slack/Teams on deploy. But even without those, you now have a practical CI/CD pipeline that will save you time every week.


Leave a Reply

Your email address will not be published. Required fields are marked *