GitHub Actions is the CI/CD system you already have without installing anything: it lives inside your repository, fires on every push or pull request and runs anything that fits in a container. The entry barrier is understanding the YAML anatomy — once you master it, writing a new workflow takes minutes.
Minimal anatomy: a Node CI
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Use 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: Lint
run: npm run lint --if-present
- name: Type check
run: tsc --noEmit --if-present
- name: Build
run: npm run build --if-present
Each piece has its job:
on:defines the triggers. This workflow runs on pushes tomainand on all pull requests (no branch filter), which is exactly what you want from CI.strategy.matrixclones the job per combination: here the same build runs against Node 20 and Node 22 in parallel. If something breaks only on one version, you know before merging.actions/checkout@v4brings your code onto the runner. It is always the first step; the@v4pins the action's major version.cache: 'npm'in setup-node caches~/.npmacross runs. Without it, every CI run downloads all dependencies from scratch.
The --if-present suffix prevents failure when your project lacks that script — useful for generic workflows shared across repos (note: it is native to npm; with yarn or pnpm verify support in your version).
Runners and the real cost
runs-on: ubuntu-latest gives you an ephemeral machine with 4 vCPUs and 16 GB RAM living exactly as long as the job. Public repositories get unlimited free minutes; private ones have a monthly quota (2000 on the free plan) multiplied by OS — Linux 1x, Windows 2x, macOS 10x. For a standard Node CI, that quota covers hundreds of monthly runs.
Secrets: injecting credentials without committing them
Secrets are configured under Settings → Secrets and variables → Actions and reach the workflow as ${{ secrets.NAME }}. Rules worth knowing:
- They never appear in logs: GitHub automatically masks them in any output.
- Not inherited from forks: a workflow triggered by an external pull request cannot access your secrets — the fundamental exfiltration protection.
- A secret is usable, never readable: you can write it to a file or pass it as an environment variable, but you can never view it again in the UI.
Here is the deployment-to-Vercel-from-Actions pattern:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Pull Vercel environment
run: npx vercel@latest pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build project
run: npx vercel@latest build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel
run: npx vercel@latest deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
The pull → build → prebuilt deploy sequence builds on the GitHub runner and uploads only the final artifact. The token is created in Vercel (Account Settings → Tokens) scoped to the project.
Publishing Docker images to GHCR
The GitHub Container Registry (ghcr.io) stores Docker images next to your code, with visibility tied to the repo. The full workflow:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Three details separating this workflow from a basic tutorial:
- Least-privilege
permissions:: the job explicitly declares it only reads content and writes packages. Since GitHub enabled restricted defaults, declaring them is security best practice (and required in organizations with strict policies). ${{ secrets.GITHUB_TOKEN }}needs no setup: GitHub generates it per run and rotates it automatically. It is the repo's internal token, distinct from a Personal Access Token.- Tagging strategy: current branch (
main), short commit hash (full traceability: every image points to its exact commit) andlatestonly on the default branch. Never deploy fromlatest; use the SHA tag. cache-from/to: type=gha: Docker layers cache within Actions infrastructure; later builds reuse intact layers.
Common beginner mistakes
Workflow does not trigger: almost always the branch filter. branches: [main] does not fire on pushes to other branches — if you develop on feat/x, the trigger will be the PR when opened.
Invalid YAML from interpolation: ${{ }} inside strings needs quote care; if your editor lacks YAML syntax highlighting, paste it into a validator before pushing (a workflow broken by syntax stays silent: it simply never runs).
Dependent jobs: by default all jobs run in parallel. When you need sequence (build → deploy), use needs: build in the second job.
Generate your workflows from templates
Node matrix CI, Vercel deploy or GHCR publishing: our GitHub Actions Generator produces all three complete workflows, choosing package manager and triggers (push, PR, manual) via toggles.
FAQ
Does Actions replace Jenkins or GitLab CI? For projects hosted on GitHub, practically yes: native integration, huge marketplace and zero infrastructure to maintain. Jenkins lives on in enterprises with very specific on-premise requirements.
Can I run browser tests? Yes, with Playwright or Cypress directly on the Ubuntu runner, or using service containers (services: in the job) for databases during tests.
How do I debug a failing workflow? Add a temporary step with run: ls -la && cat package.json to inspect runner state, or use tmate.io (the mxschmitt/action-tmate action) to open an interactive SSH session into the runner.
Generate your copy-ready workflow with the GitHub Actions Generator, free and right in your browser.