TL;DR: GitHub Actions is GitHub's built-in CI/CD system. You define workflows as YAML files that run on push, pull request, or on a schedule. In this article, I'll walk you through everything: from your first workflow to matrix builds, Docker images, self-hosted runners, and reusable workflows.
Imagine pushing code and everything just happens automatically: linting, testing, building, deploying. No more manual fiddling. Sounds great? That's exactly what CI/CD does — and GitHub Actions is the easiest way to get there.
🤔 What Is CI/CD and Why Should You Care?
CI (Continuous Integration) means your code gets automatically tested on every push. CD (Continuous Deployment/Delivery) ensures that tested code gets automatically deployed. Together, they form the backbone of modern software development.
Without CI/CD, here's what happens: you forget to run tests. A colleague merges broken code. Deployment becomes a Friday afternoon nightmare. With CI/CD? Push, sit back, done.
GitHub Actions is built right into GitHub — no external service, no extra setup. All you need is a YAML file in your repository.
🧩 Core Concepts: Workflows, Jobs, Steps, Runners
Before we dive in, a quick overview of the building blocks:
- Workflow: A YAML file under
.github/workflows/. Defines when and what happens. - Job: A group of steps that run on the same runner. Jobs can run in parallel or sequentially.
- Step: A single command or action. Steps run one after another within a job.
- Runner: The machine where your job executes. GitHub provides free runners (Ubuntu, Windows, macOS) — or you can use your own.
- Action: A reusable building block. Thousands of them are available on the GitHub Marketplace.
🚀 Your First Workflow: Lint and Test on Push/PR
Let's start simple. Create the file .github/workflows/ci.yml in your repository:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm testThat's it. On every push to main and every pull request, linting and tests run automatically. You'll see the green check or red X right in the PR.
🔢 Matrix Builds: Test Across Multiple Node.js Versions
Want to make sure your code works on different Node.js versions? Matrix builds make it trivial:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm testGitHub automatically creates three parallel jobs — one for each version. You can expand the matrix for different operating systems too:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20]
runs-on: ${{ matrix.os }}That gives you 6 combinations. Automatically. No copy-paste.
⚡ Caching: Faster Builds Through Dependency Caching
Running npm ci every time is slow. Caching saves you precious minutes:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ciThe setup-node action has caching built right in. Alternatively, you can use the generic cache:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-First build? Normal speed. Every subsequent one? Noticeably faster. On large projects, this easily saves 2-3 minutes per run.
🔐 Secrets and Environment Variables
API keys, tokens, passwords — they don't belong in your code. GitHub Secrets are made for this:
# Set secrets via GitHub CLI
gh secret set MY_API_KEY --body "super-secret-value"
gh secret set DOCKER_PASSWORD --body "my-docker-password"In your workflow, access them like this:
env:
API_URL: https://api.example.com
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.MY_API_KEY }}
run: |
curl -H "Authorization: Bearer $API_KEY" $API_URL/deployImportant: Secrets are automatically masked in logs. You'll only see *** instead of the actual value. And in pull requests from forks, secrets aren't available — an important security mechanism.
🐳 Building and Pushing Docker Images
Docker and GitHub Actions are a dream team. Here's how to build and push an image to Docker Hub or GitHub Container Registry:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}The beauty of it: GITHUB_TOKEN is provided automatically. No extra secret needed for GitHub Container Registry.
🌐 Deployment: Vercel, Netlify, or via SSH
The build passed, tests are green — now ship it.
Vercel:
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'SSH to your own server:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
git pull origin main
npm ci --production
pm2 restart myappWhether PaaS or bare metal — there's an action for everything.
♻️ Reusable Workflows and Composite Actions
Copy-pasting across multiple repos? No thanks. Reusable workflows solve this elegantly:
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
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 testThen call it like this:
# .github/workflows/ci.yml
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'Composite Actions are even more granular — perfect for reusable step groups that you want to share across repos.
🏗️ Practical Example: The Complete Pipeline
Now let's put it all together. A real pipeline: lint, test, build, deploy — with dependencies between jobs:
name: Full Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
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/
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
echo "Deploying build artifacts..."
# Your deployment command hereNote the needs property: build waits for lint and test. Deploy waits for build. And deploy only runs on main with a push — not on pull requests. Clean.
🏠 Self-Hosted Runners for Your Homelab
GitHub's runners are great for most projects. But sometimes you need more control: specialized hardware, access to your local network, or simply unlimited build minutes.
# Set up a runner on your server
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf ./actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/YOUR-USER/YOUR-REPO --token YOUR-TOKEN
./run.shIn your workflow, just switch the runner:
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: echo "Running on my own server!"For homelab setups, this is gold. You can access local services directly, build Docker images locally, and deploy everything within your network.
💰 Pricing: What Does It Cost?
| Plan | Minutes/Month | Storage | Price |
|---|---|---|---|
| Free | 2,000 | 500 MB | $0 |
| Team | 3,000 | 2 GB | $4/user/month |
| Enterprise | 50,000 | 50 GB | $21/user/month |
Important: These minutes only apply to private repos. Public repos get unlimited minutes. And self-hosted runners don't consume any minutes — yet another reason for that homelab.
macOS runners cost 10x as much as Linux runners. Windows runners cost double. If you want to save: stick with Linux and only use macOS builds when absolutely necessary.
💡 Conclusion
GitHub Actions is a powerful tool that lives right inside your repository. No external service, no complicated setup. You write a YAML file and get automated tests, builds, and deployments in return.
Start small: a simple CI workflow with linting and tests. Then expand step by step — caching, matrix builds, Docker, deployment. And if you need more control, set up a self-hosted runner in your homelab.
The 2,000 free minutes per month are more than enough for most personal projects. And for open-source projects? Completely free. No more excuses for not using CI/CD.
Happy automating! 🚀
Discover more articles
GitHub Actions: CI/CD für deine Projekte 🔄
TL;DR: GitHub Actions ist das eingebaute CI/CD-System von GitHub. Du definierst Workflows als YAML-Dateien, die bei Push, Pull Request oder nach Zeitplan laufen. In diesem Artikel zeige ich dir alles: von deinem ersten Workflow über Matrix-Builds und Docker-Images bis hin zu Self-Hosted Runnern und wiederverwendbaren Workflows. Stell dir
Spoolman: Your filament reel management
Lost track of your filaments? I have a solution for that! 🛵
Automatically archive GitHub repos
Automatically archive your repos when they are outdated? I will show you how you can do it 🔥