GitHub Actions is the built-in CI/CD platform inside GitHub. It lets you automate everything from running tests on every push to deploying to production — all defined in YAML files that live in your repository. It is free for public repos and has a generous free tier for private repos. As of 2025, it is the most widely used CI/CD tool in the world.
This tutorial takes you from zero — no prior CI/CD experience needed — to writing real pipelines that lint, test, build, and deploy code.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Core concepts | Understand workflows, jobs, steps, runners |
| Triggers | Start pipelines on push, PR, schedule, or manually |
| Actions | Use community actions from the marketplace |
| Secrets | Store API keys and passwords securely |
| Matrix builds | Test across multiple OS / language versions |
| Real pipelines | CI for Node.js, Python, and Docker deploy |
How GitHub Actions works
GitHub Actions uses a simple model:
Repository event → Workflow → Job(s) → Step(s)
- Event: something that happens — a push, a pull request, a schedule, a manual trigger.
- Workflow: a YAML file in
.github/workflows/. One repository can have many workflows. - Job: a unit of work that runs on a runner (a virtual machine). Jobs run in parallel by default.
- Step: a single command or action inside a job. Steps run sequentially.
- Action: a reusable plugin. The GitHub Marketplace has thousands (
actions/checkout,actions/setup-node, etc.). - Runner: the VM that executes jobs. GitHub provides Ubuntu, Windows, and macOS runners for free.
Setup
You need:
- A GitHub account (free at github.com)
- A repository to work in
No installation required — GitHub Actions runs entirely in the cloud.
Create the workflow directory in your repo:
mkdir -p .github/workflows
Every .yml file in .github/workflows/ becomes a workflow automatically.
Your first workflow
Create .github/workflows/hello.yml:
name: Hello World
on:
push:
branches: [main]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Print greeting
run: echo "Hello from GitHub Actions!"
- name: Show environment
run: |
echo "Runner OS: $RUNNER_OS"
echo "Branch: $GITHUB_REF_NAME"
echo "Commit: $GITHUB_SHA"
Push this file. Go to Actions tab in GitHub. You will see the workflow run.
Workflow syntax deep dive
name
Human-readable name shown in the GitHub UI.
name: CI Pipeline
on — triggers
| Trigger | Example | When it fires |
|---|---|---|
push |
on: push |
Any push to any branch |
push + branches |
branches: [main, dev] |
Push to specific branches |
pull_request |
on: pull_request |
PR opened, synchronised, or reopened |
schedule |
cron: '0 6 * * 1' |
Every Monday at 06:00 UTC |
workflow_dispatch |
on: workflow_dispatch |
Manual trigger from the UI |
release |
types: [published] |
When a GitHub Release is published |
workflow_call |
on: workflow_call |
Called by another workflow |
on:
push:
branches: [main]
paths:
- 'src/**' # only trigger when src/ changes
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * *' # nightly at 02:00 UTC
workflow_dispatch: # manual run button
jobs
jobs:
build: # job ID (can be anything)
name: Build App # optional display name
runs-on: ubuntu-latest
steps:
- run: echo "building"
test:
name: Run Tests
runs-on: ubuntu-latest
needs: build # wait for build job to finish
steps:
- run: echo "testing"
needs creates a dependency — test only starts if build succeeds.
runs-on — runner options
| Value | OS |
|---|---|
ubuntu-latest |
Ubuntu 24.04 |
ubuntu-22.04 |
Ubuntu 22.04 |
windows-latest |
Windows Server 2025 |
macos-latest |
macOS 14 (Apple Silicon) |
macos-13 |
macOS 13 (Intel) |
self-hosted |
Your own runner machine |
steps
steps:
- name: Checkout code # use an action
uses: actions/checkout@v4
- name: Run a command # run a shell command
run: npm test
- name: Multi-line command
run: |
npm ci
npm run build
npm test
- name: Step with env var
run: echo "Deploying to $ENVIRONMENT"
env:
ENVIRONMENT: production
Environment variables and context
GitHub provides many built-in variables:
| Variable | Value |
|---|---|
GITHUB_SHA |
Commit SHA that triggered the run |
GITHUB_REF |
Full ref (e.g. refs/heads/main) |
GITHUB_REF_NAME |
Short name (e.g. main) |
GITHUB_ACTOR |
Username that triggered the run |
GITHUB_REPOSITORY |
owner/repo |
GITHUB_WORKSPACE |
Path where the repo is checked out |
GITHUB_RUN_ID |
Unique ID for this workflow run |
RUNNER_OS |
Linux, Windows, or macOS |
Access them in shell steps: $GITHUB_SHA (Linux/Mac) or $env:GITHUB_SHA (PowerShell).
Expression context — access structured data in YAML:
- run: echo "Actor is ${{ github.actor }}"
- run: echo "Event is ${{ github.event_name }}"
- run: echo "Ref is ${{ github.ref }}"
Secrets and variables
Never hardcode passwords, API keys, or tokens. Use secrets.
Adding a secret
- Go to Settings → Secrets and variables → Actions
- Click New repository secret
- Enter name (e.g.
DOCKERHUB_PASSWORD) and value
Using a secret
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
Secret values are masked in logs — they appear as ***.
Variables (non-secret config)
# Set in Settings → Secrets and variables → Variables
- run: echo "Deploying to ${{ vars.DEPLOY_ENV }}"
Inline env vars for a job
jobs:
deploy:
env:
NODE_ENV: production
PORT: 3000
steps:
- run: node server.js
Commonly used actions
The GitHub Marketplace has 20,000+ actions. These are the essential ones:
| Action | Purpose | Example |
|---|---|---|
actions/checkout@v4 |
Check out your repo | Required for almost every workflow |
actions/setup-node@v4 |
Install Node.js | node-version: '20' |
actions/setup-python@v5 |
Install Python | python-version: '3.12' |
actions/setup-java@v4 |
Install Java/JDK | java-version: '21' |
actions/setup-go@v5 |
Install Go | go-version: '1.22' |
actions/cache@v4 |
Cache dependencies | Speed up builds |
actions/upload-artifact@v4 |
Save build output | Pass between jobs |
actions/download-artifact@v4 |
Retrieve saved output | Use in later job |
docker/login-action@v3 |
Log in to a registry | Docker Hub, GHCR, ECR |
docker/build-push-action@v6 |
Build and push image | Full Docker CI |
Caching dependencies
Without caching, every run re-downloads all packages. With caching, runs are 2–5× faster.
Node.js (npm):
- 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
Python (pip):
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
- run: pip install -r requirements.txt
setup-node and setup-python also have a built-in cache option:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # built-in caching — simplest option
Matrix builds
Run the same job across multiple configurations in parallel.
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
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 versions × 2 OS). All must pass for the workflow to succeed.
Exclude a combination:
strategy:
matrix:
node: [18, 20]
os: [ubuntu-latest, windows-latest]
exclude:
- node: 18
os: windows-latest
Passing data between jobs
Jobs run on separate machines, so you need artifacts to share files.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download build output
uses: actions/download-artifact@v4
with:
name: dist-files
path: dist/
- run: ls dist/
Job outputs (pass small values, not files):
jobs:
setup:
outputs:
version: ${{ steps.get-version.outputs.version }}
steps:
- id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
build:
needs: setup
steps:
- run: echo "Building version ${{ needs.setup.outputs.version }}"
Conditional steps and jobs
steps:
- name: Only on main branch
if: github.ref == 'refs/heads/main'
run: echo "Deploying to production"
- name: Only on failure
if: failure()
run: echo "Something went wrong"
- name: Always run (even if previous step failed)
if: always()
run: echo "Cleanup"
Conditional jobs:
jobs:
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying..."
if functions:
| Function | Meaning |
|---|---|
success() |
All previous steps succeeded (default) |
failure() |
At least one previous step failed |
cancelled() |
Workflow was cancelled |
always() |
Run no matter what |
Project 1: Node.js CI pipeline
A complete CI workflow for a Node.js app:
name: Node.js CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: Test (${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-node${{ matrix.node-version }}
path: coverage/
build:
name: Build
runs-on: ubuntu-latest
needs: test
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/
Project 2: Python CI pipeline
name: Python CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install ruff pytest pytest-cov
- name: Lint with ruff
run: ruff check .
- name: Run tests with coverage
run: pytest --cov=src --cov-report=xml
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-py${{ matrix.python-version }}
path: coverage.xml
Project 3: Docker build and push to Docker Hub
Build a Docker image on every push to main and push it to Docker Hub.
Prerequisites: Add these secrets in GitHub Settings:
DOCKERHUB_USERNAMEDOCKERHUB_TOKEN(Docker Hub access token — not your password)
name: Docker Build & Push
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/my-app
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
On pull requests, this builds but does not push (push: false). On merges to main, it builds and pushes.
Reusable workflows
Extract common logic into a reusable workflow to avoid duplication.
Create .github/workflows/reusable-test.yml:
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: true
type: string
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
- run: npm test
Call it from another workflow:
name: CI
on:
push:
branches: [main]
jobs:
test-node-20:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
test-node-22:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '22'
Composite actions
Create your own reusable action (stored in the same repo):
.github/actions/setup-app/action.yml:
name: Setup App
description: Install deps and cache
inputs:
node-version:
description: Node.js version
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
shell: bash
Use it:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-app
with:
node-version: '20'
- run: npm test
Scheduled and manual workflows
Run nightly:
on:
schedule:
- cron: '0 3 * * *' # every day at 03:00 UTC
Cron syntax: minute hour day-of-month month day-of-week
Manual with inputs:
on:
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
type: choice
options: [staging, production]
dry-run:
description: Dry run only
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to ${{ inputs.environment }}"
- run: echo "Dry run: ${{ inputs.dry-run }}"
Permissions and GITHUB_TOKEN
Every workflow run gets an automatic GITHUB_TOKEN secret. You can use it without any setup.
steps:
- name: Create a comment on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Tests passed! ✓'
})
Control what GITHUB_TOKEN can do:
permissions:
contents: read # read repo files
pull-requests: write # comment on PRs
packages: write # push to GitHub Container Registry
Set permissions at workflow or job level.
Environments and protection rules
Environments let you add manual approval gates and environment-specific secrets.
- Go to Settings → Environments
- Create
stagingandproduction - Add required reviewers to
production
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: echo "Deploying to staging"
deploy-production:
runs-on: ubuntu-latest
environment: production # ← requires manual approval
needs: deploy-staging
steps:
- run: echo "Deploying to production"
The workflow pauses at deploy-production until an approver clicks Review deployments in the GitHub UI.
GitHub Actions vs alternatives
| Feature | GitHub Actions | Jenkins | GitLab CI | CircleCI | Bitbucket Pipelines |
|---|---|---|---|---|---|
| Hosting | Cloud (GitHub) | Self-hosted | Cloud + self | Cloud | Cloud |
| Config language | YAML | Groovy / YAML | YAML | YAML | YAML |
| GitHub integration | Native | Plugin | Good | Good | Limited |
| Free tier (public) | Unlimited | N/A | Unlimited | Unlimited | 50 min/mo |
| Free tier (private) | 2,000 min/mo | Self-hosted | 400 min/mo | 6,000 min/mo | 50 min/mo |
| Marketplace | 20,000+ actions | 1,800+ plugins | Limited | Orbs | Limited |
| Self-hosted runners | Yes | Native | Yes | Yes | Yes |
| Matrix builds | Yes | Manual | Yes | Yes | Limited |
| Reusable workflows | Yes | Shared libs | Templates | Orbs | Pipes |
| Setup difficulty | Low | High | Low | Low | Low |
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Hardcoding secrets | Secret visible in git history | Always use ${{ secrets.NAME }} |
| Not pinning action versions | Breaking changes on updates | Use @v4 not @main |
Missing actions/checkout |
Workflow has no code | Always checkout first |
Running on ubuntu-latest without testing |
Unexpected breakage when runner updates | Test on specific versions for critical pipelines |
No needs on dependent jobs |
Jobs run in wrong order | Use needs: [job-id] |
| Uploading artifacts you don't need | Slow runs, high storage usage | Only upload what the next job needs |
Exposing secrets via echo |
Visible in plain text in logs | GitHub masks secrets but avoid echoing them |
| Large self-hosted runners for simple tasks | Wasted cost | Use GitHub-hosted for simple jobs |
GitHub Actions vs related terms
| Term | What it is |
|---|---|
| GitHub Actions | The CI/CD platform built into GitHub |
| Workflow | A YAML file in .github/workflows/ that defines automation |
| Job | A set of steps that run on one runner |
| Step | A single task: run a command or use an action |
| Action | A reusable plugin from the Marketplace or your own repo |
| Runner | The VM that executes a job (GitHub-hosted or self-hosted) |
| Artifact | A file or directory saved from one job to use in another |
| Environment | A deployment target with secrets and approval rules |
| GITHUB_TOKEN | Auto-generated token for API calls within the workflow |
| Matrix | Run the same job in multiple configurations in parallel |
| Jenkins | Self-hosted CI/CD server, older but very flexible |
| CircleCI | Cloud CI/CD, popular before GitHub Actions |
Learning path
| Stage | What to learn | Time |
|---|---|---|
| 1 | First workflow, triggers, run commands | 1–2 days |
| 2 | Checkout, setup-node/python, run tests | 2–3 days |
| 3 | Secrets, env vars, conditional steps | 2–3 days |
| 4 | Matrix builds, caching, artifacts | 3–5 days |
| 5 | Docker push, deploy workflows, environments | 1 week |
| 6 | Reusable workflows, composite actions, OIDC | 1–2 weeks |
| 7 | Self-hosted runners, GitHub API, advanced security | Ongoing |
Free resources:
- GitHub Actions docs:
docs.github.com/en/actions - GitHub Skills (interactive):
skills.github.com - GitHub Marketplace:
github.com/marketplace?type=actions
Quick reference
# Full workflow skeleton
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
- if: failure()
run: echo "Something failed"
6 FAQ
Q: Is GitHub Actions free? A: Free for public repositories (unlimited minutes). Private repos get 2,000 minutes/month on the free plan. Storage for artifacts is limited to 500 MB on the free plan. Self-hosted runners are free (you pay for the machine).
Q: What is the difference between on: push and on: pull_request?
A: push fires when a commit is pushed directly to a branch. pull_request fires when a PR is opened, synchronised (new commit pushed to the PR branch), or reopened. Both are commonly used together so tests run on both direct pushes and PRs.
Q: How do I pass a secret to a Docker build?
A: Use --secret with Docker Buildx. Never pass secrets as --build-arg — they end up in image layers. Example:
- uses: docker/build-push-action@v6
with:
secrets: |
"npmtoken=${{ secrets.NPM_TOKEN }}"
Then in your Dockerfile: RUN --mount=type=secret,id=npmtoken npm install.
Q: Why did my workflow not trigger?
A: Common reasons: the YAML file has a syntax error (validate with actionlint), the branch filter does not match, the event type is wrong (e.g. pull_request vs push), or the file is not in .github/workflows/. Check the Actions tab for error messages.
Q: How do I run a workflow only when specific files change?
A: Use paths or paths-ignore under on.push:
on:
push:
paths:
- 'src/**'
- 'package.json'
paths-ignore:
- '**.md'
Q: What is OIDC and why should I use it?
A: OpenID Connect lets GitHub Actions authenticate with cloud providers (AWS, GCP, Azure) without storing long-lived credentials as secrets. Instead, GitHub issues a short-lived token for each run. It is the recommended way to deploy to cloud providers. Setup: add the OIDC provider in your cloud console, then use the provider's official action (aws-actions/configure-aws-credentials@v4, etc.).