← Back to DevOps

GitHub Actions Tutorial: Automate Your Workflow From Scratch

GitHub Actions lets you run automated tasks directly from your repository without third-party services. You can build, test, and deploy code using workflows triggered by repository events like pushes, pull requests, or schedules—all in YAML files stored in your repo.

What Are GitHub Actions?

GitHub Actions is a CI/CD platform built into GitHub. Instead of maintaining separate Jenkins servers or relying on external services like CircleCI, you define automation workflows as YAML files in your repository. When specific events occur—like a commit to main or a new pull request—GitHub automatically executes the defined jobs.

The platform's strength lies in its simplicity and integration. You don't install agents on servers. You don't configure webhooks. GitHub handles everything. Your workflows run on GitHub-hosted runners (Ubuntu, Windows, macOS) or your own self-hosted machines.

Why Use GitHub Actions?

Core Concepts You Need to Know

Before writing your first workflow, understand the terminology.

Workflows

A workflow is an automated process defined in a YAML file. It lives in the .github/workflows/ directory of your repository. One repo can have multiple workflows—one for testing, one for deployment, one for security scanning.

Jobs

A job is a set of steps that execute in sequence on the same runner. Jobs within a workflow run in parallel by default, but you can create dependencies so one job waits for another to complete.

Steps

A step is an individual task—running a shell command, calling an action, or checking out code. Steps execute sequentially within a job and share the working directory.

Actions

Actions are reusable units of code. Instead of writing shell scripts, you use pre-built actions from the GitHub Marketplace. Common examples: actions/checkout (clones your repo), actions/setup-node (installs Node.js), actions/upload-artifact (stores build outputs).

Runners

A runner is a server that executes your workflow. GitHub provides hosted runners (GitHub-hosted), or you can run jobs on your own machines (self-hosted runners).

Building Your First Workflow

Let's create a simple workflow that runs tests every time code is pushed.

Step 1: Create the Workflow File

In your repository root, create a directory and file:

.github/workflows/test.yml

Step 2: Define the Trigger

Specify when the workflow runs:

name: Run Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

This workflow triggers on pushes to main/develop branches and on any pull request targeting main.

Step 3: Add a Job

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm install
      - run: npm test

This job checks out your code, installs Node 18, installs dependencies, and runs tests. The uses: keyword invokes pre-built actions. The run: keyword executes shell commands.

Complete Example

name: Run Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16, 18, 20]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Notice the strategy.matrix section. This runs the entire job across three Node versions in parallel, catching version-specific bugs instantly.

Real-World Workflows

Automated Deployment to AWS

Deploy to production when code lands on main:

name: Deploy to AWS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: aws s3 sync dist/ s3://my-bucket/
      - run: aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }} --paths "/*"

Secrets are stored in repository settings and injected at runtime—never hardcoded in YAML.

Docker Image Build and Push

Build and push to Docker Hub on every release:

name: Build and Push Docker Image

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v2
      - uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: myusername/myapp:${{ github.ref_name }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The GitHub Actions cache speeds up builds by reusing Docker layers across runs.

Scheduled Health Checks

Run synthetic tests every 6 hours:

name: Health Check

on:
  schedule:
    - cron: '0 */6 * * *'

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - run: curl https://api.example.com/health --fail
      - run: curl https://example.com --fail
      - uses: actions/github-script@v6
        if: failure()
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '⚠️ Health check failed!'
            })

Use cron syntax for scheduled workflows. The if: failure() condition only runs if previous steps failed.

Best Practices for GitHub Actions

Use Action Versions Explicitly

Always pin action versions. Never use @latest or @main:

✗ uses: actions/checkout@main
✓ uses: actions/checkout@v4

This prevents unexpected breaking changes when action maintainers push updates.

Store Secrets Securely

Go to Settings → Secrets and Variables → Actions in your repository. Add secrets like API keys, tokens, and credentials. Reference them as ${{ secrets.SECRET_NAME }}. GitHub masks secret values in logs automatically.

Cache Dependencies

Speed up builds by caching node_modules, pip packages, or Maven artifacts:

- uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

Fail Fast on Linting

Run linters and type checkers before expensive tests. Kill the workflow early if code quality fails:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run lint:fix
      - run: npm run type-check

  test:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      # test steps here

The needs: keyword creates job dependencies. The test job won't start until lint succeeds.

Use Conditions Wisely

Skip steps based on branch, event type, or outputs:

- name: Deploy to production
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: npm run deploy:prod

Common Pitfalls to Avoid

Hardcoding secrets: Never paste API keys or tokens in YAML files. Always use repository secrets.

Ignoring matrix failures: When you use a matrix strategy, one job failure stops the entire workflow. Use continue-on-error: true if you want partial failures to not block downstream jobs.

Forgetting checkout: Most workflows need actions/checkout@v4 as the first step. Without it, your code doesn't exist in the runner's filesystem.

Bloated workflows: If a workflow file exceeds 500 lines, consider splitting it into multiple smaller workflows. Maintenance becomes difficult otherwise.

Not testing workflows locally: Use act to run workflows on your machine before pushing. It catches syntax errors and logic bugs early.

Advanced Features

Manual Workflow Dispatch

Allow manual triggering from the GitHub UI:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy to'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying to ${{ inputs.environment }}"

Reusable Workflows

Share workflows across repositories. Create a .github/workflows/shared-test