GitHub Actions CI/CD: Build a Pipeline From Scratch
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:
- 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
needsplus joboutputs, or upload and download artifacts. - Parallel by default. List three jobs with no
needsand all three start at once. Useneeds: buildto 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 testactions/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
| Aspect | GitHub Actions | Jenkins | GitLab CI |
|---|---|---|---|
| Hosting | Managed runners, zero setup | You run and patch the server | Managed or self-hosted |
| Config | YAML in .github/workflows/ | Groovy / UI / Jenkinsfile | YAML .gitlab-ci.yml |
| Ecosystem | Large action marketplace | Vast plugin ecosystem | Built-in templates |
| Best fit | Repos already on GitHub | Complex, on-prem, legacy pipelines | Teams on GitLab |
| Secrets | Built-in, scoped GITHUB_TOKEN | Credentials plugin | CI/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 oractions/cache. It's the biggest single win against slow runs. - Scope
GITHUB_TOKENpermissions 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 frommain, behind an approval where it matters. - Fail fast and keep jobs parallel. Independent jobs (lint, test, type-check) should run at once;
only add
needswhere 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
needsoutputs 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
needsfor 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
mainwithif:and protect production with environments. - Use the scoped
GITHUB_TOKENand 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
- GitHub Actions documentation — the authoritative guide to workflows, jobs, steps, and runners.
- Workflow syntax for GitHub Actions —
the full reference for
on,jobs,needs, andif. - Caching dependencies to speed up workflows — how built-in and manual caching work.
- Automatic token authentication (
GITHUB_TOKEN) — the scoped, short-lived token and its permissions.

