Skip to main content
DevOps Insightsguides & answers

CI/CD · beginner

Setting Up a CI/CD Pipeline with GitHub Actions

Short answer

A workflow is YAML in .github/workflows/ that runs on a runner when an event fires. Getting one working takes an afternoon. The three things that decide whether it stays useful are: what permissions the job holds, whether a pull request from a fork can reach your secrets, and whether the actions you call are pinned to something that cannot change under you.

N
Neeraj Jha
Updated 8 min read435 views
On this page · 12 sections
Diagram: a git push starting a GitHub Actions workflow whose build, test and lint jobs feed package and then deploy.

Who this is for

You have a repository on GitHub and tests that currently run on somebody's laptop. You do not need Docker, a cloud account, or a self-hosted runner to follow this.

The pieces

TermWhat it is
WorkflowOne YAML file, triggered by an event
JobA set of steps on one runner, isolated from other jobs
StepOne command, or one call to a published action
RunnerThe machine that executes a job

Jobs run in parallel by default and get a fresh runner each. Two jobs do not share a filesystem — if the second needs something the first built, that is what artifacts and needs: are for.

A workflow that earns its keep

yaml
name: CI

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

# Least privilege, set explicitly. Without this block the job gets whatever the
# repository default is, which is frequently more than a test run needs.
permissions:
  contents: read

# A second push to the same pull request cancels the first. Without it a busy
# branch queues runs nobody is waiting for any more. Pushes to main are never
# cancelled, so a second merge cannot stop a deploy halfway through.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: "npm"

      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Four details worth the space:

npm ci rather than npm install. It installs exactly what the lockfile says and fails if package.json and the lockfile disagree. install will quietly resolve a newer version, which is how a build passes locally and fails here — or worse, passes here and ships something nobody tested.

cache: "npm" in setup-node already caches the npm download directory, keyed on your lockfile. You usually do not need a separate actions/cache step as well; adding one often just caches the same directory twice.

timeout-minutes because the default is generous. A hung test does not fail — it occupies a runner until something eventually stops it.

concurrency because the alternative is paying for runs whose results are already stale.

Integration tests need a database

yaml
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

The --health-cmd block is not decoration. Without it the container is "started" the moment Docker returns, and your first test connects before Postgres is accepting connections. That failure is intermittent, which makes it expensive — it gets re-run, it passes, and nobody investigates.

Deploying only after tests pass

yaml
  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/checkout@v7
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/deploy.sh

needs: test makes the dependency real; if: keeps it off pull requests. environment: is the one that is easy to skip, and it is where required reviewers and environment-scoped secrets live — so a token that can reach production is not readable by every other job in the repository.

The security part, which is most of it

Secrets and forks. A pull_request run from a fork does not receive your secrets. That is a deliberate protection: the code in that PR is written by someone who does not have write access. If a workflow appears to break only for outside contributors, this is usually why, and the answer is to split the job, not to reach for pull_request_target — which runs in the base repository's context with secrets. It runs the base branch's workflow, and it becomes dangerous the moment that workflow checks out and builds the fork's code — a combination that has been the root of real compromises.

Pin actions to a commit SHA.

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

A tag is a moving pointer. Whoever controls the action's repository can move v4 to different code, and your workflow will run it on the next trigger without a commit on your side. A SHA cannot be moved. Dependabot updates these, so the cost is a pull request rather than vigilance.

Set permissions: explicitly, as in the workflow above, and add only what each job needs — id-token: write for OIDC, packages: write to publish.

Stop storing long-lived cloud keys. OIDC lets a workflow exchange a short-lived GitHub token for cloud credentials, so there is no secret to leak or rotate. When the trust policy is wrong it fails in one specific way, which this site covers: Not authorized to perform sts:AssumeRoleWithWebIdentity.

Matrix builds

yaml
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24, 26]

fail-fast: false is worth setting deliberately. The default cancels the whole matrix when one entry fails, which is efficient and hides whether the failure is version-specific — usually the one thing you ran a matrix to find out.

ubuntu-latest is changing under this workflow

Added 22 October 2026. Both jobs above run on ubuntu-latest, and GitHub is moving that label from Ubuntu 24.04 to Ubuntu 26.04 between 19 October and 19 November 2026. Most workflows notice nothing. Three changes can reach this one:

  • /tmp is a RAM disk with a quota on 26.04 images built before a fix merged on 20 October 2026. A job that writes gigabytes to /tmp can fail with "disk quota exceeded" while the disk still has space. Make the first step echo "TMPDIR=$RUNNER_TEMP" >> "$GITHUB_ENV" so later steps write to the work disk instead.
  • Containers get 1024 open files instead of 1048576. Docker 29 uses systemd's default limit, and prints nothing about it. A busy database in a service container can fail later with "Too many open files". Raise it with Docker's --ulimit nofile=... option where the container is started.
  • Preinstalled versions jump: python3 from 3.12 to 3.14, the default Node.js from 22 to 24, Docker from 28 to 29, Compose from v2 to v5, Helm from 3 to 4, PostgreSQL from 16 to 18. The workflow above picks its Node.js version with actions/setup-node, so the new default does not affect it; anything that relied on a preinstalled tool should pin it the same way.

To keep the old image while you test, change the label to runs-on: ubuntu-24.04. The full list, each item with the check that shows whether it affects you, is in the ubuntu-latest 26.04 notes, and the /tmp failure has its own page: disk quota exceeded in /tmp on Ubuntu 26.04.

Common mistakes

  1. Secrets echoed into logs. GitHub masks known secret values, but redaction relies on an exact match, so it cannot reliably mask one you derived — base64 it, URL-encode it, or pull one field out of a JSON secret, and it can appear in plain text in a log anyone with read access can see. Register a derived value with ::add-mask:: before it is printed.
  2. ${{ ... }} interpolated straight into run:. The expression is substituted into the shell script before it runs, so a value containing shell metacharacters is executed. Pass it through env: and reference "$VAR" instead. Branch names and issue titles are attacker-controlled.
  3. Caching the wrong thing. A cache keyed on something that rarely changes is a cache that serves stale dependencies. Key on the lockfile hash.
  4. Treating a green check as a deploy gate without branch protection. The check is advisory until a rule requires it.

If this did not work

  • The workflow does not run at all. Check the file is on the default branch and under .github/workflows/. A workflow added only on a feature branch will not trigger for push events on other branches.
  • "Resource not accessible by integration." The job's permissions: are narrower than what a step needs. Add the single scope, not write-all.
  • It passes locally and fails here. Usually an environment difference — Node version, a missing env var, or a test depending on files Git does not track. Print the versions in a step before assuming the runner is at fault.
  • Intermittent failures on a service container. See the health-check note above.

Next steps

Turn on branch protection requiring this workflow to pass, then add a second workflow on a schedule to run the same tests against the latest dependency versions — that catches an upstream break on a day you changed nothing, rather than during a release.

Mentioned in this article

Enjoyed this article?

Get more DevOps insights delivered to your inbox.

Get new posts by email

Subscribe to get an email when a new blog post is published. Skip anytime.

No spam, unsubscribe anytime.

N

Written by

Neeraj Jha

Platform administrator and lead writer.

View all posts →
Terms used here (4)

Short definitions for the vocabulary on this page, in case any of it is new. Each links to the full entry.

CI/CD
Continuous Integration and Continuous Delivery/Deployment — the practice of automating the build, test, and release process. CI merges code changes frequently and runs automated tests. Continuous delivery keeps every change that passes in a releasable state, so it can go to production at any time; continuous deployment goes one step further and releases every passing change to production automatically.
DevOps
A way of building and running software in which the people who write it and the people who operate it share responsibility for how it behaves in production, and automate the path between the two. In practice that means small changes merged often (continuous integration), an automated route to production (continuous delivery), infrastructure described in code, and monitoring that tells the team quickly when something breaks. It is a set of practices and a way of working rather than a single tool. DORA's research measures it with four numbers: how often a team deploys, how long a change takes to reach production, how often a deployment fails, and how quickly the team recovers when one does.
Jenkins
An open-source automation server widely used for building CI/CD pipelines. Jenkins supports hundreds of plugins for integration with virtually every tool in the DevOps ecosystem, though it requires more operational overhead than newer alternatives.
Pipeline
An automated sequence of stages that code passes through from commit to production. A typical pipeline includes building, testing, security scanning, staging deployment, and production release, with gates between each stage.

All 59 terms in the glossary

Discussion

0 comments

Sign in to join the conversation.

Be the first to comment

Start a conversation about this post

Share: