github actionsci cddevopsautomation

GitHub Actions explained: CI, Vercel deploys and Docker images to GHCR

How GitHub Actions workflows work: triggers, Node version matrix, secrets, least-privilege permissions and three production-ready templates.

August 23, 2026·8 min read

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 to main and on all pull requests (no branch filter), which is exactly what you want from CI.
  • strategy.matrix clones 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@v4 brings your code onto the runner. It is always the first step; the @v4 pins the action's major version.
  • cache: 'npm' in setup-node caches ~/.npm across 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:

  1. They never appear in logs: GitHub automatically masks them in any output.
  2. Not inherited from forks: a workflow triggered by an external pull request cannot access your secrets — the fundamental exfiltration protection.
  3. 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) and latest only on the default branch. Never deploy from latest; 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 (builddeploy), 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.

Try it without code

GitHub Actions

CI/CD ready to paste.

Open GitHub Actions

Built by

Miguel Ángel Colorado Marin (MACM)

Full-Stack Developer · Guadalajara, España

I develop web apps, digital tools and full projects — from design to deployment.

Contact me