DevgainsDevgainsDevgains
All articles

GitHub Actions CI/CD: Build a Pipeline From Scratch

·10 min read·Updated Jul 16, 2026

GitHub Actions is the CI/CD system built directly into GitHub: you commit a YAML file describing what should happen when code is pushed, and GitHub runs it on managed machines called runners — no separate CI server to operate. This tutorial builds a real GitHub Actions CI/CD pipeline from an empty file: run tests on every push, cache dependencies so it stays fast, build a Docker image, and deploy on a merge to main. Along the way you'll learn the four building blocks — workflows, jobs, steps, and runners — and how they fit together. It sits in the Devgains devops cluster next to the deploy-to-production pillar, because a pipeline exists to get that image shipped safely.

Quick answer: how GitHub Actions works

A GitHub Actions pipeline is a hierarchy of four things:

  • Workflow — a YAML file in .github/workflows/. It defines when to run (the trigger) and what to run. A repo can have many workflows.
  • Job — a unit of work that runs on a fresh runner. Jobs run in parallel by default; you can make one wait for another with needs.
  • Step — a single command or reusable action inside a job. Steps in a job run in order and share the same filesystem.
  • Runner — the virtual machine that executes a job (GitHub-hosted, like ubuntu-latest, or your own self-hosted machine).

The one-line mental model: a workflow is triggered by an event, spins up runners to execute jobs, and each job runs its steps in order on a clean machine. Every job starts from a blank VM, so anything one job produces for another must be passed explicitly.

Why CI/CD in GitHub matters

Continuous Integration means every push is automatically built and tested, so broken code is caught in minutes instead of in someone else's branch a week later. Continuous Delivery/Deployment extends that to shipping. Putting it inside GitHub has real advantages:

  • Zero infrastructure to run. GitHub-hosted runners are provisioned on demand and thrown away after each job. There's no Jenkins box to patch.
  • It lives with your code. The pipeline is versioned in the repo, reviewed in pull requests, and changes with the branch — no config drift between "the CI server" and main.
  • A huge action marketplace. Common steps — checking out code, setting up a language, logging into a registry — are prebuilt actions you reference in one line.

The payoff is a tight feedback loop: open a pull request, and within minutes you know whether it builds, passes tests, and is safe to merge.

Architecture: events, jobs, and runners

A workflow is driven by an event. A push or pull_request event, a schedule, a manual workflow_dispatch, or an API call all trigger workflows whose on: clause matches. When triggered, GitHub reads the workflow and schedules its jobs. Each job is handed to a runner — a fresh VM — which runs the job's steps top to bottom and then is destroyed.

Two consequences fall out of this design and trip up newcomers:

  1. Jobs are isolated. Because every job gets a clean runner, a file written in job A is not visible in job B. To move data between jobs you either use needs plus job outputs, or upload and download artifacts.
  2. Parallel by default. List three jobs with no needs and all three start at once. Use needs: build to force ordering — for example, deploy only after tests pass.

This is why a typical pipeline is a small graph: a test job and a build job may run in parallel, and a deploy job declares needs: [test, build] so it only runs once both succeed.

Step-by-step: build the pipeline

Create .github/workflows/ci.yml. Start with the trigger and a test job. This runs on every push and every pull request, checks out the code, sets up Node with built-in dependency caching, and runs the suite:

name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  test:
    runs-on: ubuntu-latest        # the runner
    steps:
      - uses: actions/checkout@v4 # a prebuilt action: clone the repo onto the runner
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"            # cache ~/.npm keyed on package-lock.json
      - run: npm ci               # a shell step: install exactly the locked versions
      - run: npm test

actions/checkout and actions/setup-node are reusable actions from the marketplace; run steps are plain shell. The cache: "npm" line is doing heavy lifting — it restores the npm cache between runs so installs don't re-download everything. Slow pipelines are usually slow because of cache misses, and this is the first place to fix that.

Next, add a job that builds and pushes a Docker image — but only on main, and only after tests pass. Note needs: test (ordering) and the if condition (branch gating):

  build-and-push:
    runs-on: ubuntu-latest
    needs: test                                   # wait for the test job
    if: github.ref == 'refs/heads/main'           # only on main, not on PRs
    permissions:
      contents: read
      packages: write                             # allow pushing to GHCR
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # auto-provided, scoped token
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

Two things to notice. First, secrets.GITHUB_TOKEN is an automatically generated, short-lived token scoped by the permissions: block — you don't create or store it, which keeps credentials out of the repo. Second, the image is tagged with github.sha, the exact commit — an immutable tag so you always know precisely what's deployed, a practice covered in the deploy-to-production pillar. For a lean image to push here, use multi-stage builds.

Finally, a deploy job gated on a protected environment so a human (or a rule) can approve production rollouts:

  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    if: github.ref == 'refs/heads/main'
    environment: production          # can require approval / restrict secrets
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}:${{ github.sha }}

The environment: production key lets you require a manual approval before this job runs and scope production secrets to it. Your deploy script should perform a zero-downtime rolling update so users never see a gap. That's the full pipeline: test on every push, build and push an immutable image on main, deploy behind an approval gate.

GitHub Actions vs other CI tools

AspectGitHub ActionsJenkinsGitLab CI
HostingManaged runners, zero setupYou run and patch the serverManaged or self-hosted
ConfigYAML in .github/workflows/Groovy / UI / JenkinsfileYAML .gitlab-ci.yml
EcosystemLarge action marketplaceVast plugin ecosystemBuilt-in templates
Best fitRepos already on GitHubComplex, on-prem, legacy pipelinesTeams on GitLab
SecretsBuilt-in, scoped GITHUB_TOKENCredentials pluginCI/CD variables

The takeaway: if your code already lives on GitHub, Actions is the path of least resistance — no server to run, config next to the code, and an action for almost everything.

Best practices

  • Pin actions to a major version (actions/checkout@v4) or a commit SHA for security-sensitive ones, so a compromised or breaking update doesn't silently enter your pipeline.
  • Cache dependencies with setup-* caching or actions/cache. It's the biggest single win against slow runs.
  • Scope GITHUB_TOKEN permissions to the minimum each job needs (permissions:), rather than leaving the default broad token.
  • Gate deploys with if: and environments. Run tests on every PR, but only build and deploy from main, behind an approval where it matters.
  • Fail fast and keep jobs parallel. Independent jobs (lint, test, type-check) should run at once; only add needs where there's a real dependency.
  • Never echo secrets. Reference them as ${{ secrets.NAME }}; GitHub masks them in logs, but don't print them yourself.

Common mistakes

  • Expecting files to survive across jobs. Each job is a clean runner — pass data via needs outputs or artifacts, not by assuming a shared disk.
  • Running the deploy job on pull requests. Without an if: github.ref == 'refs/heads/main' guard, a PR can trigger a production deploy. Gate it.
  • No dependency caching. The most common reason a pipeline crawls; installs re-download every run.
  • Using mutable image tags like :latest. You lose the link between a running container and the commit that produced it. Tag with the SHA.
  • Over-broad token permissions. The default token is powerful; scope it down per job.

Key takeaways

  • A workflow is triggered by an event and runs jobs on runners; each job runs its steps in order.
  • Jobs are isolated and parallel by default — use needs for ordering and artifacts/outputs to pass data.
  • Cache dependencies to keep CI fast; cache misses are the usual cause of slow pipelines.
  • Gate build and deploy jobs to main with if: and protect production with environments.
  • Use the scoped GITHUB_TOKEN and immutable SHA tags for secure, traceable deploys.

FAQ

What is GitHub Actions? GitHub Actions is a CI/CD platform built into GitHub. You define workflows as YAML files in your repository; when an event like a push or pull request occurs, GitHub runs the workflow's jobs on managed machines called runners. It automates testing, building, and deploying without a separate CI server.

What is the difference between a workflow, a job, and a step? A workflow is the whole YAML file and its trigger. A job is a unit of work that runs on one fresh runner; jobs run in parallel unless you add dependencies. A step is a single command or action inside a job, and steps run in order sharing the job's filesystem.

What is a runner in GitHub Actions? A runner is the virtual machine that executes a job. GitHub-hosted runners (like ubuntu-latest) are provisioned on demand and destroyed after the job. You can also register self-hosted runners on your own hardware for special requirements like GPUs or private-network access.

How do I make one job wait for another? Add a needs: key listing the jobs that must finish first. For example, needs: test makes a job start only after the test job succeeds. This is how you build a graph like test → build → deploy instead of everything running at once.

How do I handle secrets in GitHub Actions? Store secrets in the repository or environment settings and reference them as ${{ secrets.NAME }}. GitHub masks them in logs. For pushing to GitHub's own registry, use the automatically provided, short-lived GITHUB_TOKEN and scope its permissions per job rather than storing a personal token.

Conclusion

A GitHub Actions pipeline is just four ideas stacked together: an event triggers a workflow, which runs jobs on fresh runners, each executing steps in order. Start with a test job on every push, add a build job that produces an immutable, SHA-tagged image on main, and finish with a deploy job behind an environment gate. Cache your dependencies, scope your tokens, and keep independent jobs parallel, and you get fast, secure feedback on every commit. From here, make sure the image you ship is lean with multi-stage Docker builds, roll it out safely with zero-downtime deploys, follow the full deploy-to-production pillar, and browse the devops category.

References

10 min read

Read next