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.

Contents
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.ymlruns tests on pull requestsdeploy-production.ymldeploys the main branchsecurity-scan.ymlchecks dependenciesscheduled-tests.ymlruns 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 Case | Variable | Secret |
|---|---|---|
| Public API base URL | Yes | No |
| Environment name | Yes | No |
| Feature flag | Yes | Sometimes |
| Database password | No | Yes |
| Deployment token | No | Yes |
| Private API key | No | Yes |
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.
| Cache | Artifact |
|---|---|
| Speeds up future workflow runs | Stores workflow output |
| Usually contains dependencies | Usually contains reports or builds |
| Restored automatically using a key | Downloaded or passed between jobs |
| Should not be treated as permanent | Designed 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
qualityjob checks code quality - The
buildjob creates the application build - The
deployjob runs only after a successful build onmain
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:
- Open the repository
- Go to Actions
- Select the failed workflow run
- Open the failed job
- Expand the red step
- 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
runuses
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.