GitHub Actions CI/CD: Complete Beginner to Advanced Guide
GitHub Actions is the most widely adopted CI/CD platform for open-source and private repositories. This guide covers everything from your first workflow to advanced patterns: matrix builds, reusable workflows, Docker-based CI, OIDC deployments, self-hosted runners, and debugging techniques.
What Are GitHub Actions?
GitHub Actions is a workflow automation platform built directly into GitHub. It runs arbitrary code in response to events that happen in your repository — a push, a pull request, a release tag, a schedule, or a manual trigger. Each workflow is defined as a YAML file under .github/workflows/ in your repository.
The key concepts are:
- Workflow — a YAML file that defines one or more jobs, triggered by one or more events.
- Event — the trigger:
push,pull_request,schedule,workflow_dispatch, etc. - Job — a set of steps that runs on a single runner (VM). Jobs run in parallel by default.
- Step — an individual task within a job: a shell command (
run) or a pre-built action (uses). - Action — a reusable unit of automation, either from the GitHub Marketplace or defined inline. Examples:
actions/checkout@v4,docker/build-push-action@v5. - Runner — the machine that executes the job. GitHub-hosted runners are Ubuntu, Windows, or macOS VMs. You can also bring your own self-hosted runners.
Your First Workflow: Hello World
Create a file at .github/workflows/ci.yml in your repository:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
When you push this file to your repository, GitHub detects it immediately. The next push to main or any pull request targeting main will trigger this workflow. The job runs on an Ubuntu VM, checks out your code, sets up Node.js 20, installs dependencies, runs tests, and builds.
Validate Your Workflow YAML
GitHub Actions workflows are YAML files. Catch indentation errors and syntax issues before you push with our free YAML Validator — client-side, instant feedback.
Open YAML ValidatorWorkflow Triggers (on:)
The on: key controls when your workflow runs. Here are the most useful triggers:
Push and Pull Request
on:
push:
branches: [main, 'release/**']
paths:
- 'src/**' # only trigger if files in src/ changed
- '!src/**/*.md' # but ignore .md files in src/
tags:
- 'v*' # trigger on version tags like v1.0.0
pull_request:
branches: [main]
types: [opened, synchronize, reopened] # default behavior
Schedule (Cron)
on:
schedule:
- cron: '0 2 * * *' # daily at 02:00 UTC
- cron: '0 9 * * 1' # every Monday at 09:00 UTC
Scheduled workflows run against the default branch. They only run if the workflow file exists on that branch. Use our Cron Parser to verify your cron expressions.
Manual Trigger (workflow_dispatch)
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options: [staging, production]
dry_run:
description: 'Dry run (no actual deploy)'
type: boolean
default: false
This adds a "Run workflow" button in the GitHub UI under the Actions tab. You can pass inputs that are accessible via ${{ inputs.environment }} in the workflow. This is ideal for deployment workflows you want to trigger on demand.
Other Useful Triggers
on:
release:
types: [published] # runs when you publish a GitHub Release
workflow_run:
workflows: ["CI"]
types: [completed] # runs after another workflow completes
repository_dispatch: # triggered via GitHub API from external systems
types: [deploy]
Jobs: Parallelism and Dependencies
By default, all jobs in a workflow run in parallel. Use needs: to create dependencies between jobs.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
build:
runs-on: ubuntu-latest
needs: [test, lint] # only runs if both test and lint succeed
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
deploy:
runs-on: ubuntu-latest
needs: build # only runs if build succeeds
if: github.ref == 'refs/heads/main' # only deploy from main
steps:
- run: echo "Deploying..."
Here test and lint run in parallel. build waits for both. deploy only runs if build passes AND we are on the main branch.
Matrix Builds
Matrix builds let you run the same job across multiple combinations of values — different Node.js versions, operating systems, or any other dimension — without duplicating code.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18 # skip this specific combination
fail-fast: false # don't cancel other matrix jobs on first failure
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This generates 8 parallel jobs (3 OS x 3 Node versions, minus the excluded combination). The matrix values are accessible as ${{ matrix.os }} and ${{ matrix.node }} throughout the job.
Secrets and Environment Variables
Never hardcode credentials in workflow files. GitHub provides encrypted secrets storage at the repository, environment, and organization level.
# Add a secret in GitHub: Settings > Secrets and variables > Actions > New repository secret
# Then reference it in your workflow:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # uses environment-level secrets
steps:
- name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
aws s3 sync ./dist s3://my-bucket/
GitHub automatically redacts secret values if they appear in log output, replacing them with ***. Secrets are only exposed to workflow runs triggered from the repository itself — forks do not get access to parent repository secrets.
Environment secrets are scoped to a named deployment environment (like production) and can require manual approval before a job accessing them starts — a critical guard for production deployments.
Caching Dependencies
GitHub Actions runners start fresh for every job. Without caching, every run re-downloads packages. For a large Node project, that is 60+ seconds per run. The actions/cache action solves this.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # built-in caching shortcut for npm/yarn/pnpm
- run: npm ci
- run: npm test
The cache: 'npm' shortcut in setup-node automatically caches ~/.npm using package-lock.json as the cache key. For more control:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
The cache key includes the hash of package-lock.json. When the lockfile changes, a new cache is created. restore-keys provides fallback prefixes so a partial cache hit is used when an exact match is not found.
Artifacts: Sharing Files Between Jobs
Artifacts persist files from one job so other jobs (or humans) can download them. This is how you pass a compiled build from a build job to a deploy job.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to S3
run: aws s3 sync dist/ s3://my-bucket/
Docker Containers in CI
GitHub Actions integrates deeply with Docker — you can build images, push to registries, and even run your CI steps inside a custom container.
Build and Push to Docker Hub or ECR
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
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: |
myorg/myapp:latest
myorg/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The cache-from: type=gha / cache-to: type=gha lines enable BuildKit layer caching via GitHub Actions cache, dramatically speeding up subsequent builds when only a few layers change.
Run Steps Inside a Container
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:20-alpine
env:
NODE_ENV: test
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://postgres:testpass@postgres:5432/testdb
The services: block spins up sidecar containers (a PostgreSQL database here) that your test steps can connect to by hostname (postgres in this case). This is the cleanest way to run integration tests against a real database in CI.
Reusable Workflows
Reusable workflows let you extract a workflow into its own file and call it from other workflows — eliminating duplication across repositories or multiple workflow files.
# .github/workflows/deploy-reusable.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
image_tag:
required: true
type: string
secrets:
AWS_ROLE_ARN:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy
run: |
echo "Deploying ${{ inputs.image_tag }} to ${{ inputs.environment }}"
# .github/workflows/ci.yml — calling the reusable workflow
jobs:
build:
# ... build job ...
deploy-staging:
needs: build
uses: ./.github/workflows/deploy-reusable.yml
with:
environment: staging
image_tag: ${{ github.sha }}
secrets:
AWS_ROLE_ARN: ${{ secrets.STAGING_AWS_ROLE_ARN }}
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/deploy-reusable.yml
with:
environment: production
image_tag: ${{ github.sha }}
secrets:
AWS_ROLE_ARN: ${{ secrets.PROD_AWS_ROLE_ARN }}
OIDC: Keyless AWS / GCP / Azure Auth
Instead of storing long-lived AWS access keys as secrets, GitHub Actions supports OpenID Connect (OIDC). Your workflow exchanges a short-lived GitHub token for temporary cloud credentials, with no static keys stored anywhere.
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
role-session-name: github-actions
aws-region: us-east-1
- name: Deploy
run: aws s3 sync dist/ s3://my-bucket/
For this to work, you must set up an IAM OIDC identity provider in AWS pointing to token.actions.githubusercontent.com and configure the IAM role trust policy to allow only your specific repository (and optionally branch) to assume it.
Self-Hosted Runners
GitHub-hosted runners are great for most cases, but self-hosted runners give you control over the hardware, installed software, network access to private resources, and cost (no per-minute billing).
# Target a self-hosted runner by label
jobs:
build:
runs-on: [self-hosted, linux, x64, production]
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
To register a self-hosted runner: go to your repository's Settings > Actions > Runners > New self-hosted runner, and follow the instructions for your OS. The runner agent connects outbound to GitHub — no inbound firewall rules needed.
Security note: Never use self-hosted runners on public repositories. A malicious pull request could run arbitrary code on your runner machine.
Expressions, Contexts, and Conditionals
jobs:
deploy:
runs-on: ubuntu-latest
# Only run on main branch pushes, not PRs
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Only on tag
if: startsWith(github.ref, 'refs/tags/v')
run: echo "This is a release tag"
- name: Print event info
run: |
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "SHA: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
echo "Run number: ${{ github.run_number }}"
Useful contexts: github (event metadata), env (environment variables), steps (outputs from previous steps), runner (runner info), secrets (secret values), inputs (workflow_dispatch inputs).
Passing Data Between Steps
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Generate version
id: version
run: echo "tag=v$(date +%Y%m%d)-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- name: Use version in next step
run: echo "Building ${{ steps.version.outputs.tag }}"
- name: Set environment variable for all subsequent steps
run: echo "BUILD_TAG=${{ steps.version.outputs.tag }}" >> "$GITHUB_ENV"
- name: Available in all later steps
run: echo "Tag is $BUILD_TAG"
Use $GITHUB_OUTPUT to pass values between steps within the same job. Use $GITHUB_ENV to set environment variables that persist for all subsequent steps. To pass data between jobs, use artifacts or job outputs:
jobs:
setup:
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- id: get_version
run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"
build:
needs: setup
steps:
- run: echo "Version is ${{ needs.setup.outputs.version }}"
Debugging Failed Workflows
When a workflow fails, here are the tools available:
- Step logs: Click on any step in the Actions UI to expand its log output. Look for the exact error message.
- Enable debug logging: Add a secret named
ACTIONS_STEP_DEBUGwith valuetrueto get verbose runner and action logs. AddACTIONS_RUNNER_DEBUGfor runner-level debug output. - Re-run with debug logging: In the Actions UI, click "Re-run jobs" and check "Enable debug logging" — no secret needed.
tmateSSH session: Use themxschmitt/action-tmateaction to pause a run and SSH into the runner for interactive debugging.
- name: Debug: interactive SSH session
uses: mxschmitt/action-tmate@v3
if: ${{ failure() }} # only open SSH session on failure
Production Best Practices
- Pin action versions to a full SHA, not a floating tag like
@v4:uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683. This prevents supply chain attacks where an action tag is moved to malicious code. - Use OIDC for cloud auth — never store long-lived cloud credentials as repository secrets.
- Set minimal permissions on GITHUB_TOKEN using the
permissions:key at workflow or job level. Default to read-only and grant only what each job needs. - Use environment protection rules for production deployments — require a manual reviewer to approve before the job runs.
- Cache aggressively — dependencies, Docker layers, and compiled output. This cuts average CI time by 50-70%.
- Separate CI and CD — your CI workflow tests and builds; a separate CD workflow (or deployment job) deploys. Keep them distinct for clarity and access control.
- Use concurrency groups to cancel in-progress runs when new ones are triggered:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
- Validate YAML syntax before pushing — a misconfigured workflow silently does nothing. Use our YAML Validator to catch errors locally.
- Use
timeout-minuteson jobs and steps to prevent runaway jobs from consuming all your Actions minutes. - Store reusable workflows in a dedicated
.githubrepository in your organization, and reference them across all repos withuses: org/.github/.github/workflows/deploy.yml@main.
The Bottom Line
GitHub Actions rewards investment in workflow design. Start simple — a three-step CI workflow that checks out, installs, and tests. Then layer in caching for speed, matrix builds for compatibility, OIDC for secure deployments, and reusable workflows to eliminate duplication across your organization.
The most common mistakes are: committing secrets as plaintext, using floating action tags, skipping dependency caching, and mixing CI and CD concerns in one enormous workflow file. Avoid those and you will have a fast, secure, maintainable pipeline.
Related tools: YAML Validator (validate workflow files), Cron Parser (verify schedule triggers), ENV Validator (check env vars), and 70+ more free developer tools.