Implementing CI/CD Pipelines with GitHub Actions | Emore Systems Blog
Stop wasting time debugging the Daraja API. Get a fully working M-Pesa STK Push & Callback Toolkit built in Pure PHP, ready to use on almost any PHP hosting. Check out ToolsMarketplace
Emore Systems
Ad

Track your business performance directly from all devices

Check out our Tools Marketplace for more information.

Interested

Stop wasting time debugging the Daraja API. Get a fully working M-Pesa STK Push & Callback Toolkit

Stop wasting time debugging the Daraja API. Get a fully working M-Pesa STK Push & Callback Toolkit

Interested

Managing school fees just got easier

Collect fees by M-Pesa, track balances by class and student, and get reports your finance team will actually use. Free 7-day trial, no card required.

Explore School Fees System
Ad

Does your business have a website?

Does your business have a website? If customers search for you online, can they see what you offer, your prices, location and contact you directly? We build affordable professional websites for Kenyan businesses.

Ad

Run your school's fees like a business, not a spreadsheet

Emore School System gives Kenyan boarding and day schools M-Pesa fee collection, class and student balance tracking, guardian and transport records, and CBC-ready reports — built for administrators, not accountants with a computer science degree.

Ad

New: School Fees & M-Pesa Management

Kenyan boarding and day schools can now collect fees by M-Pesa, track balances by class, and run reports admins actually understand — all in one dashboard.

DevOps

Implementing CI/CD Pipelines with GitHub Actions

Step-by-step guide to setting up continuous integration and deployment with GitHub Actions.

David Kim

David Kim

DevOps Engineer

Aug 1, 2026 7 min read 85 views
Implementing CI/CD Pipelines with GitHub Actions

I've lost count of how many times a "quick fix" pushed straight to production has taken down a client's checkout flow at 6pm on a Friday. Every single one of those incidents was preventable, and not with some exotic tooling — just a pipeline that runs the tests before the code ships and automates the deploy so a tired human isn't typing kubectl commands from memory. That's really the whole pitch for CI/CD. It's not glamorous, but it's the difference between "we found the bug in a two-minute build" and "we found the bug in production, from an angry customer."

In this post I'll walk through how I set up CI/CD pipelines with GitHub Actions on real projects — from the basic building blocks through a working example workflow you can adapt, including the parts most tutorials skip: caching, secrets, environment gates, and the mistakes that turn a helpful pipeline into a liability.

What CI/CD actually solves

Continuous Integration means every time someone pushes code, an automated process builds it and runs the test suite. That's it. The value isn't the automation itself — it's that breakage gets caught in minutes, on the commit that caused it, instead of days later when nobody remembers what changed. I've seen teams go from "who broke the build?" archaeology sessions to just glancing at a red X on a pull request and knowing exactly which line to fix.

Continuous Deployment (or delivery, if you keep a manual approval step) takes it further: once code passes CI, it gets packaged and shipped without a person SSHing into a server and running commands by hand. Manual deploys are where typos, skipped steps, and "I forgot to restart the service" incidents live. Automating that step doesn't just save time — it removes an entire category of human error.

The anatomy of a GitHub Actions workflow

If you've never written one, the mental model is simpler than it looks. A workflow is a YAML file in .github/workflows/, and it has four core concepts:

  • Triggers (on:) — what causes the workflow to run. Usually on: push for commits to a branch, and on: pull_request so checks run before code merges. You can also trigger on tags, schedules (cron), or manually via workflow_dispatch.
  • Jobs — a workflow is made of one or more jobs, each running on its own fresh virtual machine. Jobs run in parallel by default unless you tell one to needs another.
  • Steps — the individual commands inside a job, run in order on the same machine. A step can run a shell command directly or call a reusable "action" (a packaged bit of automation, like actions/checkout which pulls your repo onto the runner).
  • Runners — the actual machine executing your job. GitHub gives you free hosted runners (ubuntu-latest, windows-latest, macos-latest), or you can register your own self-hosted runner if you need specific hardware or network access.

That's the whole vocabulary. Everything else is just steps calling tools you already use locally — npm, pytest, docker, kubectl — inside that structure.

A concrete example workflow

Here's a workflow I've used as a starting template for a Node app that builds a Docker image. It installs dependencies, lints, tests, builds, then builds and pushes an image — only on pushes to main, while pull requests just get the checks without the image push:

name: CI/CD

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run lint
        run: npm run lint

      - name: Run tests
        run: npm test -- --ci

      - name: Build app
        run: npm run build

  docker-build-push:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

Notice the second job only runs after build-and-test succeeds, and only on main — a pull request from a feature branch runs the checks but never pushes an image. That separation matters more than it looks; it's what stops half-tested code from ever reaching a registry.

Docker inside the pipeline

The docker/build-push-action step above is doing the heavy lifting: it builds the image from your Dockerfile and pushes it straight to GitHub Container Registry (ghcr.io), tagged with the commit SHA so every image is traceable back to the exact code that produced it. If you'd rather use Docker Hub, swap the registry and login credentials — the shape of the step doesn't change. Tagging by SHA (instead of always overwriting latest) is worth doing from day one; it's the only thing that lets you say "roll back to the image from three commits ago" with any confidence.

Deploying to Kubernetes

Once the image is pushed, the deploy step just needs to tell your cluster to pull the new tag. In practice I add a job like this after the Docker build:

  deploy:
    needs: docker-build-push
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Set up kubectl
        uses: azure/setup-kubectl@v4

      - name: Configure cluster access
        run: echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig.yaml

      - name: Roll out new image
        run: |
          kubectl --kubeconfig=kubeconfig.yaml set image \
            deployment/my-app my-app=ghcr.io/${{ github.repository }}:${{ github.sha }} \
            -n production
          kubectl --kubeconfig=kubeconfig.yaml rollout status deployment/my-app -n production

kubectl set image triggers a rolling update, and rollout status makes the job wait and fail loudly if the new pods don't come up healthy — which is exactly the signal you want before anyone declares the deploy done.

Secrets management

Never, ever hardcode credentials in your YAML — not "temporarily," not "just for testing." GitHub Actions has repo-level secrets (Settings → Secrets and variables → Actions) that you reference as ${{ secrets.NAME }} and that never appear in logs. For anything touching production — kubeconfig, cloud API keys, database passwords — use environment secrets instead of repo secrets. Environments let you scope a secret to, say, "production" only, and layer required reviewers on top, so a secret meant for prod literally cannot leak into a workflow run against a feature branch.

Caching and deploy gates

The cache: 'npm' line in the setup-node action above isn't decoration — it caches node_modules between runs, and on a mid-sized project that alone can cut a few minutes off every single build. The same principle applies to Docker layer caching and language-specific dependency caches (pip, Maven, Go modules); if your pipeline is slow, caching is almost always the first place to look before you start splitting jobs.

The other lever is environments with required reviewers. Setting environment: production on a job (as I did in the deploy job above) lets you require one or more people to click "approve" before that job runs, even though everything upstream ran automatically. This gives you full CI/CD speed for everything up to the point of actually touching prod, with a human checkpoint exactly where you want one.

Where pipelines go wrong

The failure mode I see most often isn't a missing feature — it's over-engineering. Teams add fifteen conditional steps, six reusable workflows, and a dozen environment-specific branches until nobody on the team can explain what happens when they push code. When that pipeline breaks — and it will — debugging it costs more time than it ever saved. Keep jobs linear and readable; if you need a comment explaining what a step does, that's a sign to simplify it, not document it.

Second: have a rollback plan before you need one. Tagging images by commit SHA (as above) means rolling back is just re-running the deploy step with the previous tag — figure that out during a calm afternoon, not during an incident.

Third, and the one that bites teams a year in: CI/CD is not "set and forget." Dependency versions in your actions go stale, runners get deprecated, your test suite grows slower as the app grows, and a pipeline nobody has touched since launch quietly becomes the thing everyone routes around. Treat the workflow file with the same maintenance discipline as the application code it's shipping — because at that point, it effectively is application code.

If you're setting this up for the first time, don't try to build the full version I've shown here in one sitting. Get lint and test running on pull requests first — that alone will save you from most embarrassing bugs. Add the Docker build once that's solid, then the deploy step, then the approval gate. Each piece earns its place by catching a real problem, and that's a much sturdier pipeline than one written all at once from a checklist.

Share article

Related Articles

Sign In

Welcome back to Emore Systems

Forgot password?

No account yet?

Create Account

Join Emore as a blogger

Check Your Email

Already have an account?