Every modern development team needs a reliable way to test, build, and deploy code automatically. GitHub Actions provides a powerful CI/CD platform built directly into GitHub, eliminating the need for external services like Jenkins or CircleCI. In this guide, you will learn how to create workflows from scratch, run automated tests, build artifacts, and deploy applications — all triggered by a simple git push.
What Is CI/CD?
Continuous Integration (CI) is the practice of automatically testing and validating code every time a developer pushes changes. Instead of waiting until release day to discover bugs, CI catches them immediately.
Continuous Deployment (CD) extends CI by automatically deploying validated code to staging or production environments. Together, CI/CD creates a pipeline that takes code from commit to production with minimal manual intervention.
Why it matters: Teams using CI/CD ship faster, catch bugs earlier, and spend less time on manual release processes. A well-configured pipeline can reduce deployment time from hours to minutes.
GitHub Actions Core Concepts
Before writing your first workflow, understand these building blocks:
Workflows
A workflow is an automated process defined in a YAML file inside .github/workflows/. A repository can have multiple workflows — one for testing, another for deployment, and so on.
Jobs
A workflow contains one or more jobs. By default, jobs run in parallel, but you can configure them to run sequentially using dependencies.
Steps
Each job contains a series of steps. A step can either run a shell command or use a pre-built action from the GitHub Marketplace.
Runners
A runner is the virtual machine that executes your jobs. GitHub provides hosted runners with Ubuntu, Windows, and macOS. You can also configure self-hosted runners for specialized needs.
Actions
Actions are reusable units of code. The GitHub Marketplace offers thousands of community-built actions for common tasks like checking out code, setting up language runtimes, and deploying to cloud providers.
Creating Your First Workflow
Create a file at .github/workflows/ci.yml in your repository:
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
This workflow does the following:
- Triggers on pushes to
mainordevelop, and on pull requests targetingmain - Runs on the latest Ubuntu runner
- Checks out your repository code
- Sets up Node.js version 20
- Installs dependencies using
npm ci(clean install, faster and more reliable thannpm install) - Runs the linter and test suite
Push this file to your repository and navigate to the Actions tab to watch it execute.
Workflow Triggers
GitHub Actions supports many trigger events. Here are the most common:
on:
# Trigger on push to specific branches
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
# Trigger on pull requests
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Scheduled runs (cron syntax)
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC
# Manual trigger from the GitHub UI
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
Tip: Use
pathsfilters to avoid running expensive workflows when only documentation files change. This saves runner minutes and speeds up your feedback loop.
Using Pre-Built Actions
The actions/ organization on GitHub provides essential actions:
steps:
# Checkout your code
- uses: actions/checkout@v4
# Setup various language runtimes
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/setup-go@v5
with:
go-version: '1.22'
# Upload build artifacts
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# Download artifacts from another job
- uses: actions/download-artifact@v4
with:
name: build-output
Always pin actions to a specific major version (e.g., @v4) to avoid unexpected breaking changes.
Environment Variables and Secrets
Environment Variables
Define variables at the workflow, job, or step level:
env:
NODE_ENV: production
API_URL: https://api.knowledgexchange.xyz
jobs:
build:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgres://localhost:5432/testdb
steps:
- name: Print environment
run: echo "Building for $NODE_ENV"
env:
BUILD_NUMBER: ${{ github.run_number }}
Secrets
Never hardcode sensitive values. Store them in Settings > Secrets and variables > Actions, then reference them:
steps:
- name: Deploy to production
run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Warning: Secrets are masked in logs but can still be accidentally exposed. Never echo secrets directly or write them to files that get uploaded as artifacts.
Matrix Builds
Test across multiple versions, operating systems, or configurations simultaneously:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This creates 9 parallel jobs (3 OS x 3 Node versions). Setting fail-fast: false ensures all combinations run even if one fails, giving you a complete picture of compatibility.
Excluding and Including Combinations
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20]
exclude:
- os: windows-latest
node-version: 18
include:
- os: ubuntu-latest
node-version: 22
experimental: true
Caching Dependencies
Speed up workflows by caching downloaded dependencies:
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Built-in caching support
- run: npm ci
For more control, use the cache action directly:
steps:
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: pip install -r requirements.txt
The cache key is based on the hash of your lock file, so the cache automatically invalidates when dependencies change.
Building and Uploading Artifacts
Build your application and save the output for later jobs or download:
jobs:
build:
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 build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: production-build
path: dist/
retention-days: 7
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: production-build
path: dist/
- name: Deploy
run: echo "Deploying from dist/ directory"
The needs: build keyword ensures the deploy job waits for the build job to complete successfully.
Practical Example: Node.js Application
Here is a complete CI/CD pipeline for a Node.js application:
name: Node.js CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run unit tests
run: npm test -- --coverage
- name: Upload coverage report
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
build:
needs: lint-and-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 run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name=my-app
Practical Example: Python Application
name: Python CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12']
services:
postgres:
image: postgres:16
env:
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: Setup Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run flake8 linting
run: flake8 src/ --count --show-source --statistics
- name: Run pytest
run: pytest --cov=src --cov-report=xml
env:
DATABASE_URL: postgres://postgres:testpassword@localhost:5432/testdb
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
Notice how this workflow uses service containers to spin up a PostgreSQL database for integration tests. GitHub Actions manages the container lifecycle automatically.
Deploying a Static Site to Vercel
name: Deploy 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 build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
working-directory: ./
Reusable Workflows
Avoid duplicating workflow logic across repositories by creating reusable workflows:
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
secrets:
npm-token:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
env:
NPM_TOKEN: ${{ secrets.npm-token }}
- run: npm test
Call it from another workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}
Conditional Execution and Job Outputs
Control when steps and jobs run using conditionals:
jobs:
check:
runs-on: ubuntu-latest
outputs:
should-deploy: ${{ steps.changes.outputs.deploy }}
steps:
- uses: actions/checkout@v4
- name: Check for deployment-relevant changes
id: changes
run: |
if git diff --name-only HEAD~1 | grep -qE '^(src/|package\.json)'; then
echo "deploy=true" >> "$GITHUB_OUTPUT"
else
echo "deploy=false" >> "$GITHUB_OUTPUT"
fi
deploy:
needs: check
if: needs.check.outputs.should-deploy == 'true'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying application..."
Best Practices
- Pin action versions — Use
@v4instead of@mainto avoid unexpected breakage - Use
npm ciovernpm install— It is faster and ensures reproducible builds - Enable caching — Cache dependencies to dramatically reduce workflow run times
- Set
fail-fast: falsein matrix builds — See all failures, not just the first one - Use environments — Define
productionandstagingenvironments with approval gates - Keep secrets minimal — Only grant the permissions your workflow actually needs
- Add status badges — Display build status in your README:

- Use
concurrency— Cancel redundant workflow runs when new commits are pushed:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Debugging Failed Workflows
When a workflow fails, use these techniques:
- Check the logs in the Actions tab for the exact error message
- Add debug logging by setting the
ACTIONS_STEP_DEBUGsecret totrue - Use
actto run workflows locally:act -j test(requires Docker) - SSH into the runner for interactive debugging using
mxschmitt/action-tmate
- name: Debug with tmate
if: failure()
uses: mxschmitt/action-tmate@v3
timeout-minutes: 15
Summary
GitHub Actions provides everything you need to build a robust CI/CD pipeline without leaving GitHub. Start with a simple test workflow, then gradually add build steps, deployment targets, and matrix testing. The key is to automate the repetitive tasks that slow your team down — let machines handle testing and deployment so developers can focus on writing code.
For more on programming best practices and development workflows, explore our Programming articles. If you are deploying to cloud infrastructure, check out our guides on cloud hosting and server configuration.