Toolmingo
Guides15 min read

GitHub Actions Tutorial for Beginners (2025): CI/CD Step by Step

Complete GitHub Actions tutorial for beginners. Learn workflows, jobs, steps, triggers, secrets, and build real CI/CD pipelines. Free guide with examples.

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:

  1. A GitHub account (free at github.com)
  2. 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

  1. Go to Settings → Secrets and variables → Actions
  2. Click New repository secret
  3. 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_USERNAME
  • DOCKERHUB_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.

  1. Go to Settings → Environments
  2. Create staging and production
  3. 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.).

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools