Setting Up CI/CD with GitHub Actions
Someone merges a PR on Friday afternoon, nobody ran the tests, and the deploy script was run by hand from a laptop with a stale .env. By Monday the site is half broken and nobody's sure which commit did it. That's the problem CI/CD (Continuous Integration/Continuous Deployment) solves: every push gets built and tested the same way, and deploys happen from a known state instead of someone's machine.
If your code already lives on GitHub, GitHub Actions is the shortest path to that. This post walks through a first workflow, multi-job pipelines, secrets, matrix builds, caching, and a few complete examples for common stacks, plus the gotchas that tend to bite people the first time.
- What CI and CD actually mean in practice
- GitHub Actions core concepts: workflows, jobs, and steps
- Creating your first workflow from scratch
- Setting up automated testing pipelines
- Building and deploying applications automatically
- Working with secrets and environment variables
- Advanced patterns: matrix builds, caching, and artifacts
- Real-world workflow examples for different tech stacks
Understanding CI/CD
People use "CI/CD" as one word, but it's two separate habits. You can have the first without the second, and plenty of teams do.
Continuous Integration (CI)
Build and test every change when it's pushed. Broken code gets flagged on the PR, before it reaches main.
Continuous Deployment (CD)
Ship code that passed CI to staging or production without anyone running a script by hand. Releases get smaller and more frequent.
CI checks the code on every push; CD takes what passed and ships it
Benefits of CI/CD
| Benefit | Description | Impact |
|---|---|---|
| π Early Bug Detection | Automated tests catch issues immediately after code changes | Fewer regressions reach production |
| β‘ Faster Releases | Automated deployment eliminates manual steps | Deploying becomes routine, not an event |
| π€ Better Collaboration | Every PR is validated automatically before merge | Reviewers focus on logic, not "does it build?" |
| π Consistent Process | Same build/test/deploy process every time | Far fewer "works on my machine" arguments |
| β©οΈ Easy Rollbacks | Versioned deployments with quick rollback capability | Minimal downtime on issues |
Introduction to GitHub Actions
The main reason to pick Actions over Jenkins or CircleCI is that there's nothing extra to set up. You drop a YAML file into the repo, push, and GitHub runs it. Check results show up right on the PR, secrets live in the repo settings, and there's no separate service to log into.
- Free for public repos: unlimited minutes on standard GitHub-hosted runners
- 2,000 minutes/month free for private repos on the free plan (macOS and Windows minutes count at a higher rate)
- Built into GitHub: checks appear on PRs, and you can trigger on issues, releases, tags and more
- Marketplace: thousands of ready-made actions, though quality varies a lot
- Matrix builds: test several OS/version combinations in parallel
Core Concepts
Four terms cover almost everything: a workflow is the YAML file, an event triggers it, it contains one or more jobs, and each job is a list of steps.
A workflow contains jobs, jobs contain steps, and steps run commands or actions
Creating Your First Workflow
Start small: one workflow that installs dependencies, runs tests and runs the linter on every push. You can bolt on builds and deploys once this one is green.
Step 1: Create the Workflow Directory
GitHub only picks up workflow files from .github/workflows/ at the repo root. Put them anywhere else and they're silently ignored.
Step 2: Write Your First Workflow
Create a file at .github/workflows/ci.yml with the following content:
name: CI Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
# The type of runner the job will run on
runs-on: ubuntu-latest
steps:
# Check out the repository code
- name: Checkout code
uses: actions/checkout@v4
# Set up Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Run tests
- name: Run tests
run: npm test
# Run linting
- name: Run linter
run: npm run lint
name: the label shown in the Actions tabon: events that trigger the workflow (push, PR, schedule, etc.)jobs: one or more jobs, which run in parallel unless you chain them withneedsruns-on: the runner type (ubuntu, windows, macos)steps: commands or actions, run in orderuses: pulls in a pre-built actionrun: runs a shell command
One thing that trips people up: npm ci fails if package.json and package-lock.json are out of sync ("npm ci can only install packages when your package.json and package-lock.json are in sync"). That's on purpose. Commit the lock file, and run npm install locally after editing dependencies.
Multi-Job Workflows
Once you add a build and a deploy, it makes sense to split them into separate jobs. Each job gets a fresh runner, so anything one job produces (like a dist/ folder) has to be passed to the next as an artifact.
name: Full CI/CD Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
# Job 1: Run tests
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
# Job 2: Build the application
build:
runs-on: ubuntu-latest
needs: test # Waits for test job to complete
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
# Upload build artifacts
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# Job 3: Deploy to production
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' # Only deploy on main
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to server
run: |
echo "Deploying to production..."
# Add your deployment commands here
Jobs run sequentially when they depend on each other using the needs keyword
Working with Secrets
API keys, SSH keys and passwords don't belong in the YAML. Store them as encrypted secrets in the repo settings and GitHub injects them when the job runs.
Add Secrets in Repository Settings
Go to Settings β Secrets and variables β Actions β New repository secret
Use Secrets in Workflows
Access secrets using the ${{ secrets.SECRET_NAME }} syntax
name: Deploy with Secrets
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to server
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "$SSH_KEY" > key.pem
chmod 600 key.pem
scp -i key.pem -r ./dist/* user@$SERVER_HOST:/var/www/app/
rm key.pem
- name: Notify deployment
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Deployment successful! π"}' \
$SLACK_WEBHOOK
- Never echo or log secret values
- Use environment-specific secrets for staging vs production
- Rotate secrets regularly and after team member changes
- Limit secret access to necessary workflows only
- Use GITHUB_TOKEN for GitHub API operations (auto-provided)
- Secrets aren't passed to workflows triggered by
pull_requestfrom forks, so a deploy step that works on your own branches will see empty values on outside contributors' PRs
Matrix Builds
If you publish a library or a CLI, your users won't all be on your Node version or your OS. A matrix runs the same job once per combination of values, in parallel, so you find the Windows path bug before someone files an issue about it.
name: Matrix CI
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
# Skip Node 18 on Windows to save time
- os: windows-latest
node-version: 18
fail-fast: false # Don't cancel other jobs if one fails
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
- name: Report results
run: |
echo "β
Tests passed on ${{ matrix.os }} with Node ${{ matrix.node-version }}"
This configuration creates 8 parallel jobs (3 OS Γ 3 Node versions, minus 1 excluded combination), all running at the same time. Keep an eye on the bill for private repos, though: macOS and Windows minutes cost more than Linux ones.
Matrix builds test all combinations in parallel, catching platform-specific bugs early
Caching for Faster Builds
Without a cache, every run downloads your whole dependency tree again. Caching keeps the package cache between runs, keyed on the lock file, so it's only rebuilt when dependencies actually change.
name: CI with Caching
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Method 1: Built-in cache in setup-node
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Caches ~/.npm, not node_modules
# Method 2: Manual cache action (more control)
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- run: npm run build
- run: npm test
Cache Performance Impact
How much you save depends on the project, but the pattern is usually the same:
- Node.js projects: the install step gets noticeably shorter once the npm/yarn cache is warm
- Docker builds: layer caching helps most when the Dockerfile copies the lock file and installs before copying source
- Compiled languages: build caches pay off most on large codebases
Use one of the two methods above, not both. They cache the same directory, so doing both just adds an extra restore and save step.
Real-World Workflow Examples
Three complete workflows I'd use as starting points: a Next.js app deployed to Vercel, a Node API tested against a real Postgres container, and a Docker image pushed to Docker Hub.
React/Next.js Deployment
name: Deploy Next.js to Vercel
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}
vercel-args: '--prod'
Node.js API with PostgreSQL
name: API CI/CD
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run migrate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- run: npm test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
Docker Build & Push
name: Docker Build & Push
on:
push:
branches: [ main ]
tags: [ 'v*' ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: user/app:latest,user/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Best Practices & Tips
Security Best Practices
- Pin action versions: use
actions/checkout@v4.1.1instead of@v4or@main. For third-party actions, pinning to a full commit SHA is safer still, since tags can be moved - Audit third-party actions: read the source before you hand an action your secrets
- Use GITHUB_TOKEN: prefer the built-in token over personal access tokens
- Limit token permissions: set a
permissions:block with only what the workflow needs. If a step fails with "Resource not accessible by integration", it's usually missing a permission here - Environment protection rules: require approvals for production deployments
Performance Optimization
- Cache what's expensive to rebuild: dependencies, build outputs, and Docker layers
- Run jobs in parallel: jobs without
needsalready run at the same time - Trim the matrix: exclude combinations nobody actually uses
- Fail fast:
fail-fast: true(the default) cancels the rest of the matrix on the first failure - Choose the right runner:
ubuntu-latestusually starts fastest and costs the least
Debugging Workflows
- Enable debug logging: set the
ACTIONS_RUNNER_DEBUGandACTIONS_STEP_DEBUGsecrets (or variables) totrue, or tick "Enable debug logging" when re-running a job - Use step outputs: pass values between steps with
echo "value=$VALUE" >> $GITHUB_OUTPUT - SSH debugging:
mxschmitt/action-tmateopens an interactive session on the runner - Check run artifacts: upload logs and test reports as artifacts so you can inspect them later
- Use act locally:
nektos/actruns workflows on your machine, though it doesn't match GitHub's runners exactly
When I'd use GitHub Actions (and when I wouldn't)
If the code is on GitHub and the pipeline is "install, test, build, deploy", I'd reach for Actions first. There's no server to maintain, the YAML is versioned with the code, and PR checks just work. For most web apps and libraries that's all you need.
It's a weaker fit in a few cases:
- Your code isn't on GitHub. GitLab CI or your existing tool will be less friction.
- Long or heavy builds on private repos. Minutes add up fast, especially on macOS. Self-hosted runners help, but then you're maintaining machines again.
- Deploys that need access to a private network. GitHub-hosted runners sit on the public internet, so you'll need a self-hosted runner, a VPN step, or a pull-based deploy.
- Very complex pipelines. Once you have dozens of interdependent jobs and reusable workflows calling each other, YAML gets hard to reason about, and a dedicated tool may be easier to maintain.
Before trusting a pipeline with production, check that branch protection actually requires the CI check to pass, that the production environment requires approval, and that you've tested a rollback at least once. A green pipeline that nobody's forced to wait for doesn't protect anything.