July 18, 2026blog

GitHub Actions Tutorial for Beginners: Build, Test and Deploy

Learn GitHub Actions from beginner to advanced with practical workflow examples. Understand triggers, jobs, runners, secrets, caching, artifacts, matrix testing, deployments, and security best practices.

18 min read1,096 viewsBy Abhishek Majumdar
GitHub Actions Tutorial for Beginners: Build, Test and Deploy

GitHub Actions Tutorial for Beginners: Automate Build, Test, and Deployment

Category: Tech
Author: Abhishek, Founder at Fidusia


Your code works perfectly on your laptop. You push it to GitHub, someone else pulls it—and suddenly everything breaks.

GitHub Actions helps you catch that moment before it becomes a real problem.

When I first saw a GitHub Actions workflow, it looked more complicated than the application itself.

There was a YAML file, strange expressions such as ${{ github.ref }}, words like runner, job, artifact, and matrix, and a long list of steps that somehow turned a simple git push into an automated process.

My first thought was:

“Do I really need all this just to run npm test?”

The answer was no—you do not need everything on day one.

But once you understand the basic building blocks, GitHub Actions becomes one of the most useful tools in your development workflow. It can automatically test your code, build your application, check formatting, create deployment files, publish packages, send notifications, and deploy your application.

This guide starts from the basics and gradually moves toward real-world GitHub Actions features.


What Is GitHub Actions?

GitHub Actions is an automation platform built directly into GitHub.

It allows you to create automated workflows that run when something happens in your repository.

For example, a workflow can start when:

  • You push code
  • Someone opens a pull request
  • A pull request is merged
  • You publish a release
  • An issue is created
  • A specific time is reached
  • You manually click a button

A simple workflow might look like this:

textDeveloper pushes code
        ↓
GitHub starts a workflow
        ↓
Install dependencies
        ↓
Run linting and tests
        ↓
Build the application
        ↓
Show pass or fail result

Instead of manually repeating these steps every time, GitHub Actions performs them consistently for you.


Git Action vs GitHub Actions

Beginners often use these two terms interchangeably, but they mean different things.

GitHub Actions

GitHub Actions is the complete automation platform.

It includes:

  • Workflows
  • Events
  • Jobs
  • Steps
  • Runners
  • Secrets
  • Environments
  • Artifacts
  • Reusable workflows

An Action

An action is one reusable component used inside a workflow.

For example:

yaml- uses: actions/checkout@v7

This action downloads your repository code onto the runner.

Another example:

yaml- uses: actions/setup-node@v7
  with:
    node-version: 22

This action installs and configures Node.js.

Think of it this way:

textGitHub Actions = The complete kitchen
Action         = One reusable kitchen tool
Workflow       = The recipe
Runner         = The kitchen where the recipe is prepared

Why Should Developers Use GitHub Actions?

Imagine you are working with three teammates.

One teammate forgets to run linting. Another uses a different Node.js version. Someone pushes code that builds locally but fails in production. A pull request is merged without running the test suite.

These are not unusual mistakes. They happen because people are busy, tired, or working in different environments.

GitHub Actions gives every code change the same automated checklist.

For example:

textEvery Pull Request
    ├── Install exact dependencies
    ├── Run ESLint
    ├── Run TypeScript checks
    ├── Run tests
    └── Build the project

If any step fails, the pull request gets a failed check. The team can fix the issue before merging it.

The main benefit is not simply automation.

The real benefit is consistent feedback.


The Core Building Blocks

A GitHub Actions workflow becomes much easier to understand when you break it into small parts.


1. Workflow

A workflow is the complete automated process.

Workflow files are written in YAML and stored inside:

text.github/workflows/

A repository can have multiple workflow files:

text.github/
└── workflows/
    ├── ci.yml
    ├── deploy-production.yml
    ├── security-scan.yml
    └── scheduled-tests.yml

Each workflow can solve a different problem.

For example:

  • ci.yml runs tests on pull requests
  • deploy-production.yml deploys the main branch
  • security-scan.yml checks dependencies
  • scheduled-tests.yml runs every night

2. Event or Trigger

An event tells GitHub when the workflow should start.

Run on Every Push

yamlon: push

Run on Pull Requests

yamlon: pull_request

Run on Push and Pull Request

yamlon:
  push:
  pull_request:

Run Only for the Main Branch

yamlon:
  push:
    branches:
      - main

Run for Selected Paths

Suppose you only want the workflow to run when frontend files change:

yamlon:
  push:
    paths:
      - "apps/web/**"
      - "packages/ui/**"

This can be useful in monorepos where backend changes do not need to rebuild the frontend.

Run Manually

yamlon:
  workflow_dispatch:

This adds a Run workflow button in the GitHub Actions interface.

Run on a Schedule

yamlon:
  schedule:
    - cron: "0 2 * * *"

This can be used for nightly tests, reports, backups, or scheduled maintenance tasks.


3. Job

A job is a collection of steps that run on the same runner.

yamljobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - run: npm test

You can define multiple jobs:

yamljobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

By default, independent jobs can run in parallel.

That means linting, testing, and building may run at the same time instead of waiting for each other.


4. Step

A step is one task inside a job.

A step can either:

  • Run a shell command
  • Use a reusable action

Run a Command

yaml- name: Install dependencies
  run: npm ci

Use an Action

yaml- name: Checkout repository
  uses: actions/checkout@v7

Steps inside the same job run in order.

If an earlier step fails, later steps normally do not run.


5. Runner

A runner is the machine where your workflow executes.

Common GitHub-hosted runners include:

yamlruns-on: ubuntu-latest
yamlruns-on: windows-latest
yamlruns-on: macos-latest

For most Node.js, React, Next.js, and backend projects, Ubuntu is a practical default.

The runner is usually a fresh temporary machine. This is important because it tests whether your project can work outside your personal laptop.

If your application only works because of a package you installed globally three months ago and forgot about, the fresh runner will expose that problem.


Your First Real GitHub Actions Workflow

Let us create a practical CI workflow for a Node.js or Next.js project.

Create this file:

text.github/workflows/ci.yml

Add the following workflow:

yamlname: CI

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

permissions:
  contents: read

jobs:
  quality-check:
    name: Lint, Test and Build
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run linting
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build application
        run: npm run build

Now commit and push it:

bashgit add .github/workflows/ci.yml
git commit -m "Add GitHub Actions CI workflow"
git push origin main

Open your repository and click the Actions tab.

You should see the workflow running.


Understanding the Workflow Line by Line

Let us break down the workflow so it no longer feels like magic.

Workflow Name

yamlname: CI

This is the name displayed in the GitHub Actions interface.

You could use a more descriptive name:

yamlname: Pull Request Quality Checks

Trigger

yamlon:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

The workflow runs when:

  • Code is pushed to main
  • A pull request targets main

Permissions

yamlpermissions:
  contents: read

This gives the workflow read-only access to repository contents.

A workflow should receive only the permissions it actually needs. A test workflow usually does not need permission to modify repository content.

Job Definition

yamljobs:
  quality-check:

quality-check is the internal job ID.

The ID should not contain spaces.

Job Name

yamlname: Lint, Test and Build

This is the name shown in the GitHub interface.

Runner

yamlruns-on: ubuntu-latest

The job runs on a GitHub-hosted Ubuntu machine.

Checkout

yaml- name: Checkout repository
  uses: actions/checkout@v7

The runner starts without your project files. The checkout action downloads your repository into the workflow workspace.

Node.js Setup

yaml- name: Set up Node.js
  uses: actions/setup-node@v7
  with:
    node-version: 22
    cache: npm

This configures Node.js and enables npm dependency caching.

Use the same Node.js version that your application uses locally and in production.

Install Dependencies

yaml- name: Install dependencies
  run: npm ci

npm ci installs dependencies using the exact versions recorded in package-lock.json.

For predictable CI builds, commit your lockfile to the repository.

Run Checks

yaml- name: Run linting
  run: npm run lint
yaml- name: Run tests
  run: npm test
yaml- name: Build application
  run: npm run build

These commands must exist in your package.json.

For example:

json{
  "scripts": {
    "lint": "next lint",
    "test": "jest",
    "build": "next build"
  }
}

If your project does not currently have tests, remove the test step until you add them. A workflow should reflect the real commands available in the project.


Visualizing the Workflow

The green check is not just decoration. It tells the team that the exact automated checks defined by the project have passed.


Making Jobs Depend on Each Other

Sometimes one job should run only after another job succeeds.

For example:

yamljobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Run tests"

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploy application"

The deploy job waits for test.

If test fails, deploy does not run.

A more realistic flow could be:

textLint and Test
      ↓
Build
      ↓
Deploy
yamljobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing"

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying"

Using Conditions

The if keyword lets you decide whether a step or job should run.

Run Only on the Main Branch

yaml- name: Deploy
  if: github.ref == 'refs/heads/main'
  run: npm run deploy

Run Even When an Earlier Step Fails

yaml- name: Upload test report
  if: always()
  run: echo "Upload report"

This is useful for logs, screenshots, and test reports that you want even after a failure.

Run Only When a Previous Step Fails

yaml- name: Notify failure
  if: failure()
  run: echo "The workflow failed"

Run Only When Everything Succeeds

yaml- name: Notify success
  if: success()
  run: echo "Everything passed"

Environment Variables

Environment variables help you reuse configuration values.

Workflow-Level Variable

yamlenv:
  NODE_ENV: test

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo $NODE_ENV

Job-Level Variable

yamljobs:
  test:
    runs-on: ubuntu-latest
    env:
      API_URL: https://example.test

Step-Level Variable

yaml- name: Run tests
  env:
    NODE_ENV: test
  run: npm test

Use normal variables for non-sensitive configuration.

Do not use them for passwords, private keys, tokens, or production credentials.


Secrets

Secrets are used for sensitive values such as:

  • API tokens
  • Deployment credentials
  • Database passwords
  • Signing keys
  • Webhook secrets

Create a secret from:

textRepository
→ Settings
→ Secrets and variables
→ Actions
→ New repository secret

Suppose you create a secret named:

textDEPLOY_TOKEN

You can use it in a workflow like this:

yaml- name: Deploy application
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
  run: npm run deploy

Never hardcode a real token:

yaml# Never do this
- run: deploy --token=actual-production-token

A public repository makes that mistake visible immediately, but even private repositories can be exposed through logs, pull requests, compromised accounts, or accidental sharing.


Repository Variables vs Secrets

GitHub supports both variables and secrets.

Use CaseVariableSecret
Public API base URLYesNo
Environment nameYesNo
Feature flagYesSometimes
Database passwordNoYes
Deployment tokenNoYes
Private API keyNoYes

A useful rule is:

If exposing the value could create a security problem, store it as a secret.


Caching Dependencies

Installing dependencies on every workflow run can take time.

Caching lets workflows reuse package manager data when dependencies have not changed.

With setup-node, npm caching can be enabled like this:

yaml- name: Set up Node.js
  uses: actions/setup-node@v7
  with:
    node-version: 22
    cache: npm

For a monorepo, specify the lockfile location:

yaml- name: Set up Node.js
  uses: actions/setup-node@v7
  with:
    node-version: 22
    cache: npm
    cache-dependency-path: apps/web/package-lock.json

Caching can make repeated workflow runs faster, but it should not be treated as permanent storage.

Also, never cache files containing credentials or sensitive tokens.


Artifacts

An artifact is a file or folder produced by a workflow that you want to keep after the job finishes.

Examples include:

  • Production build output
  • Test reports
  • Coverage reports
  • Playwright screenshots
  • Failed-test videos
  • Compiled binaries
  • Generated documentation

Upload an Artifact

yaml- name: Upload test report
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-report
    path: reports/

Upload a Next.js Build Output

yaml- name: Upload build
  uses: actions/upload-artifact@v4
  with:
    name: next-build
    path: .next/

Artifacts are especially useful when a workflow fails only in CI.

Instead of staring at a red cross and guessing, you can download screenshots, logs, reports, or videos created during the failed run.


Cache vs Artifact

Beginners often confuse these two.

CacheArtifact
Speeds up future workflow runsStores workflow output
Usually contains dependenciesUsually contains reports or builds
Restored automatically using a keyDownloaded or passed between jobs
Should not be treated as permanentDesigned to preserve generated files

Use a cache to avoid downloading the same dependencies repeatedly.

Use an artifact when you want to keep or share the output of a workflow.


Matrix Testing

A matrix lets one job run with multiple configurations.

Suppose your package should support Node.js 20, 22, and 24.

Instead of copying the same job three times, use:

yamlname: Node.js Version Test

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version:
          - 20
          - 22
          - 24

    steps:
      - name: Checkout repository
        uses: actions/checkout@v7

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

GitHub creates a separate job for every Node.js version.

Matrix builds are useful for:

  • Multiple Node.js versions
  • Multiple Python versions
  • Ubuntu, Windows, and macOS
  • Multiple database versions
  • Different feature configurations

They are especially useful for libraries and tools that other developers will install in different environments.


Preventing Duplicate Workflow Runs

Imagine you push five commits within two minutes.

Without concurrency control, five workflows may continue running even though only the latest commit matters.

You can cancel older runs:

yamlconcurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

A complete example:

yamlname: CI

on:
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing latest commit"

This saves runner time and reduces noise in pull requests.

For production deployments, be more careful. You may not want a new deployment to cancel one that is already halfway through a database migration.


Reusable Workflows

As a project grows, teams often copy the same workflow into multiple repositories.

Soon, every repository has a slightly different version.

One uses Node.js 20. Another uses Node.js 22. One runs tests. Another accidentally skips them.

Reusable workflows solve this problem by letting one workflow call another.

Create a Reusable Workflow

yamlname: Reusable Node CI

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: "22"

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm

      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Call the Reusable Workflow

yamlname: CI

on:
  pull_request:

jobs:
  run-ci:
    uses: your-organization/shared-workflows/.github/workflows/node-ci.yml@main
    with:
      node-version: "22"

Reusable workflows are valuable when several repositories should follow the same engineering standards.


Deployment Environments

GitHub environments help separate deployments such as:

  • Development
  • Staging
  • Production

A deployment job can target an environment:

yamljobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Deploy
        run: echo "Deploying to production"

Environment-level configuration can help you manage:

  • Environment-specific secrets
  • Deployment approval rules
  • Protected production deployments
  • Deployment history

A practical flow could look like this:

textPull Request
    ↓
Lint, Test and Build
    ↓
Merge into Main
    ↓
Deploy to Staging
    ↓
Manual Production Approval
    ↓
Deploy to Production

That final approval step is often useful while a team is still building confidence in its test coverage.


A Practical Production Workflow Structure

For a growing application, avoid putting everything into one huge job.

A cleaner structure might look like this:

yamlname: Application Pipeline

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    name: Quality Checks
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test

  build:
    name: Build Application
    needs: quality
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run build

      - name: Upload build output
        uses: actions/upload-artifact@v4
        with:
          name: application-build
          path: .next/

  deploy:
    name: Deploy Production
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Deploy
        run: echo "Run your deployment command here"

This separates responsibilities:

  • The quality job checks code quality
  • The build job creates the application build
  • The deploy job runs only after a successful build on main

GitHub Actions for a Monorepo

Suppose your repository looks like this:

textapps/
├── web/
└── admin/

packages/
├── ui/
└── config/

You may not want every workflow to run for every small change.

Run Only When the Web App Changes

yamlon:
  pull_request:
    paths:
      - "apps/web/**"
      - "packages/ui/**"
      - "packages/config/**"

Set the Working Directory

yamldefaults:
  run:
    working-directory: apps/web

Monorepo Workflow

yamlname: Web App CI

on:
  pull_request:
    paths:
      - "apps/web/**"
      - "packages/ui/**"

defaults:
  run:
    working-directory: apps/web

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: apps/web/package-lock.json

      - run: npm ci
      - run: npm test
      - run: npm run build

For npm, pnpm, Bun, or Turborepo projects, adjust the install and build commands to match your actual repository.


Debugging a Failed Workflow

A failed workflow is not a dead end. It is a report.

When a workflow fails:

  1. Open the repository
  2. Go to Actions
  3. Select the failed workflow run
  4. Open the failed job
  5. Expand the red step
  6. Read the error from the bottom upward

Common problems include:

Missing Script

textnpm error Missing script: "test"

Your package.json does not contain the command used by the workflow.

Lockfile Mismatch

textnpm ci can only install packages when package.json and package-lock.json are in sync

Run npm install locally, commit the updated lockfile, and push again.

Missing Environment Variable

textDATABASE_URL is not defined

Add the required value as a secret or variable, depending on whether it is sensitive.

Incorrect Working Directory

textCould not find package.json

Your application may be inside a subdirectory. Set working-directory.

Different Runtime Version

A package may work on your laptop but fail on the runner because the Node.js versions are different.

Define the version explicitly in the workflow.

Tests That Need External Services

Tests may expect Redis, PostgreSQL, or another service.

You can either:

  • Mock the dependency
  • Start it as a service container
  • Connect to a dedicated test environment

Common Beginner Mistakes

1. Putting the Workflow in the Wrong Folder

Correct:

text.github/workflows/ci.yml

Incorrect:

textgithub/workflows/ci.yml

Incorrect:

textsrc/ci.yml

2. Using Tabs in YAML

YAML depends on indentation.

Use spaces, not tabs.

3. Running Commands That Do Not Exist

Before adding this:

yaml- run: npm run typecheck

Confirm that typecheck exists in package.json.

4. Hardcoding Secrets

Never commit production credentials inside a workflow.

5. Giving Excessive Permissions

A workflow that only runs tests should not receive write access to repository contents, packages, deployments, or pull requests.

6. Trusting Every Third-Party Action

An action can execute code inside your workflow.

Use actions from trusted publishers, review what permissions they need, keep them updated, and avoid adding random actions simply because a marketplace page looks convenient.

7. Building a Huge Pipeline on Day One

Start with:

textInstall → Lint → Test → Build

Add deployments, notifications, matrices, artifacts, and reusable workflows only when they solve a real problem.


Security Best Practices

GitHub Actions workflows can access code, tokens, secrets, cloud services, and deployment environments. Treat workflow changes like application code changes.

Use Minimum Permissions

yamlpermissions:
  contents: read

Add write permission only where it is required.

Protect Workflow Files

Use code review for changes inside:

text.github/workflows/

For larger teams, workflow files can also be protected through ownership and review rules.

Keep Actions Updated

Older action versions may miss security fixes or platform updates.

Use automated dependency update tools or review action versions regularly.

Use Environment Protection for Production

Production deployment should be more protected than a normal test job.

Use a dedicated environment and approval rules where appropriate.

Prefer Short-Lived Cloud Authentication

For supported cloud providers, OpenID Connect can allow a workflow to request short-lived credentials instead of storing permanent cloud keys as repository secrets.

This reduces the risk of a long-lived credential remaining valid after accidental exposure.

Do Not Print Secrets

Avoid commands that echo tokens or full environment values.

Masking helps, but you should still design workflows so sensitive values never need to appear in logs.


When Should You Use a Self-Hosted Runner?

GitHub-hosted runners are enough for many projects.

A self-hosted runner may be useful when:

  • Your application needs access to a private internal network
  • You require special hardware
  • Your build needs custom preinstalled software
  • You need more control over machine resources
  • You have specific compliance requirements

However, self-hosted runners require additional security, maintenance, updates, cleanup, and isolation.

Do not choose a self-hosted runner simply because it sounds more advanced.

Use one when the project genuinely requires it.


A Good Learning Roadmap

Do not try to learn every GitHub Actions feature at once.

Level 1: Basic CI

Learn:

  • Workflow files
  • Push and pull request triggers
  • Jobs
  • Steps
  • Runners
  • run
  • uses

Build:

textInstall → Lint → Test → Build

Level 2: Better Workflows

Learn:

  • Environment variables
  • Secrets
  • Conditions
  • Job dependencies
  • Caching
  • Artifacts
  • Manual triggers

Level 3: Team and Production Workflows

Learn:

  • Environments
  • Approval rules
  • Reusable workflows
  • Matrix testing
  • Concurrency
  • Path filters
  • Monorepo workflows

Level 4: Advanced Security and Infrastructure

Learn:

  • OpenID Connect
  • Self-hosted runners
  • Custom actions
  • Organization-level workflow standards
  • Deployment protection
  • Supply-chain security

Final Takeaway

GitHub Actions can look intimidating because even a small workflow introduces several new words at once.

But the core idea is simple:

textWhen something happens in GitHub,
run a defined list of tasks automatically.

A workflow is not meant to replace developers.

It is meant to handle the repetitive checklist that developers should not have to remember manually every time.

Start with a small CI workflow:

textCheckout code
    ↓
Install dependencies
    ↓
Run linting
    ↓
Run tests
    ↓
Build the application

Once that works reliably, improve it one real problem at a time.

Add caching when workflows are slow.

Add artifacts when debugging is difficult.

Add environments when deployments need control.

Add reusable workflows when multiple repositories repeat the same configuration.

Add OIDC when cloud credentials need stronger security.

The best GitHub Actions workflow is not the longest or most advanced one.

It is the workflow that gives your team fast feedback, protects important branches, and makes releasing software less stressful.

Automate the boring checks, catch problems early, and let developers focus on building the product.

gitFidusiaGitHub Actiongit Actiongithub