CI/CD from Zero: A Working Pipeline with GitHub Actions
CI/CD is one of those practices that feels like overhead until you work without it. Then it becomes one of those things you never want to give up: every push automatically tested, every merge automatically deployed, no more “it worked on my machine,” no more manual release processes that only one person knows how to run.
What a Good Pipeline Actually Does
Before the YAML, it’s worth being clear about what you’re building and why.
Fast feedback on broken code. The faster a developer learns their change broke something, the cheaper it is to fix. A test that runs in the developer’s IDE catches the bug in seconds. A CI check catches it in minutes. A broken deploy to staging catches it in an hour. Production catches it when users do. Each step is an order of magnitude more expensive than the previous. The primary purpose of CI is compressing this loop.
Reproducible builds. “It worked on my machine” is not a valid statement when every build runs in an identical, clean environment. CI enforces reproducibility. If it passes on every developer’s machine but fails in CI, there’s a dependency on local state that needs to be fixed. If it fails on one machine but passes in CI, you’ve found an environment issue.
Pipeline as code. The build and deployment process is version-controlled, reviewed, and changed the same way as the application code. There is no tribal knowledge about “how to deploy” that lives in one person’s head. The .github/workflows/ directory is the authoritative definition.
Deployment as a boring routine. The goal of CD is that deploying is not an event. It happens automatically, multiple times per day, with no drama. Teams that achieve this deploy more frequently, in smaller increments, with faster recovery when something goes wrong.
This article builds a real pipeline with GitHub Actions. Focus on understanding the principles each piece serves rather than copying the YAML.
What CI/CD Means
Continuous Integration (CI): every code change is automatically built and tested. If the tests pass, the change is safe to integrate. If they fail, the developer knows immediately - not when someone else pulls their code.
Continuous Delivery (CD): code that passes CI is automatically deployed to an environment (staging, production, or both). The goal is that deploying is boring - it happens all the time, automatically, with no manual steps.
The combined effect: small changes ship frequently with fast feedback. The alternative - manual testing, manual deployments, infrequent large releases - accumulates risk and slows down every team that tries it.
GitHub Actions Concepts
GitHub Actions is GitHub’s built-in CI/CD platform. It runs workflows in response to events (push, pull request, schedule, manual trigger).
Key concepts:
Workflow: a YAML file in .github/workflows/ that defines when to run and what to do.
Event: what triggers the workflow (push to main, pull request, etc.)
Job: a set of steps that run on the same machine. Jobs run in parallel by default.
Step: an individual action within a job. Either a shell command or a pre-built action.
Runner: the virtual machine that runs the job. GitHub provides Linux, macOS, and Windows runners.
Action: a reusable unit of work. Either from GitHub’s marketplace or custom.
A Basic CI Pipeline
Start simple. This workflow runs tests on every push and pull request:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
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 tests
run: npm test
- name: Run linter
run: npm run lint
Walk through what this does:
on: pushandon: pull_request- triggers for both direct pushes and PRsruns-on: ubuntu-latest- run on GitHub’s Linux runneractions/checkout@v4- clone the repository into the runneractions/setup-node@v4withcache: 'npm'- install Node.js and cache node_modulesnpm ci- clean install (faster and more deterministic thannpm install)npm testandnpm run lint- your test and lint scripts
The cache: 'npm' on the setup action saves significant time by caching the npm package cache between runs.
Adding a Database for Integration Tests
Many test suites need a database. GitHub Actions supports service containers - Docker containers that run alongside your job:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
env:
DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb
run: pytest
The --health-cmd pg_isready options ensure the runner waits for Postgres to be ready before running your steps.
Adding a Deployment Stage
A CD pipeline runs after tests pass and deploys to an environment:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
deploy:
needs: test # Only runs if test job succeeds
runs-on: ubuntu-latest
environment: production # Requires approval if configured
steps:
- uses: actions/checkout@v4
- name: Deploy to server
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
echo "$DEPLOY_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no \
deploy@$DEPLOY_HOST \
"cd /app && git pull && npm ci --omit=dev && pm2 restart app"
needs: test creates a dependency - the deploy job won’t run unless the test job succeeds. ${{ secrets.DEPLOY_SSH_KEY }} reads from GitHub repository secrets (Settings > Secrets), keeping credentials out of the workflow file.
Environment Protection Rules
For production deployments, GitHub Actions supports environment protection rules. These can require:
- Manual approval from specific reviewers before deploying
- Specific branches that can deploy (only
maincan deploy to production) - A wait timer before deployment proceeds
Configure these in Settings > Environments.
Caching Dependencies
Cache hits dramatically speed up pipelines. The setup actions handle this automatically with the cache parameter, but you can also manage cache explicitly:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
The cache key includes a hash of package-lock.json - when dependencies change, the cache key changes and npm installs fresh. Otherwise, the cached packages are restored.
Matrix Builds
Test against multiple versions of a runtime or OS:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This creates 6 parallel jobs (3 Node.js versions x 2 OSes). GitHub shows results for each combination.
What to Measure
A healthy CI pipeline:
- Runs in under 10 minutes (ideally under 5). If it’s slower, developers stop waiting for it.
- Has a high pass rate. A pipeline that frequently fails on main, not because of code bugs but because of flaky tests or infrastructure issues, loses trust.
- Blocks merges on failure. Turn on branch protection rules that require the CI check to pass before merging.
The cost of CI - a few minutes per push - is recovered immediately the first time it catches a bug before it reaches production.