Web development

GitHub Actions for a website: the minimum pipeline

A workflow that builds, keeps the artifact and deploys only from main: package versions, cache, permissions and the points where it breaks.

Key points

  • `permissions: contents: read` strips the token down to what the job uses.
  • The npm cache invalidates on the lockfile hash, not on the date.
  • `needs` plus an `if` on the ref stops a pull request from publishing.

GitHub Actions for a website is one YAML file in .github/workflows: one job that builds, one that deploys and a read-only permission. The versions measured are checkout 7.0.1 and setup-node 7.1.0.

The minimum file

name: ci
on:
  push:
    branches: [main]
  pull_request:

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

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: dist
          path: dist
          retention-days: 7

concurrency cancels the previous run on the same ref; permissions: contents: read reduces the token to the checkout alone. Without that block the token carries the repository default permissions.

The cache that invalidates itself

      - uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: npm-

The key holds the lockfile hash: change a dependency and the key changes, so the cache rebuilds. A fixed key would keep old packages for months, and the symptom would be a build that passes in CI only.

Deploy only from main

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v7
        with:
          name: dist
          path: dist
      - name: Publish
        env:
          SSH_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          install -m 600 /dev/null key
          printf "%s" "$SSH_KEY" > key
          rsync -az --delete -e 'ssh -i key' dist/ [email protected]:/var/www/site/

needs requires a green build, if limits the job to the main branch. On a pull request from a fork the secrets never arrive, so the job skips and CI stays green without exposing the key.

Artifacts and diagnosis

gh run list --limit 5
gh run watch
gh run download -n dist

upload-artifact keeps dist for seven days; gh run download -n dist brings those files back locally without rebuilding. gh run watch follows the run to the end, with the logs of the failing step.

Matrix and time

    strategy:
      fail-fast: false
      matrix:
        node: [22, 24]

The matrix duplicates the job per Node version. fail-fast: false lets the other combinations finish, so you see whether the problem sits only on the old version.

A named limitation

Minutes are billed on private repositories, and every matrix entry multiplies them. cancel-in-progress also kills a half-finished deploy when a push lands during the release: split build and deploy into two workflows when the release takes more than a few minutes.

Where to continue

Three components that end up in the build: input, spinner and the icon grid; the rest sits in components/lib and the 1,556 previews. Build failures are in common build errors, folder order in App Router in 2026. The Vite versus Next comparison for a marketing site is in Next.js vs Vite.

Frequently asked questions

What does GitHub Actions cost for a small site?

Minutes are free on public repositories; on private ones the Free plan includes 2,000 minutes a month. A two-minute build per push sits well inside that ceiling.

Where do I keep the SSH key for the deploy?

In a repository or environment secret, never in the YAML file. The step receives it as an environment variable, and the key is written to disk with 600 permissions.

Why is the deploy job skipped on pull requests?

Because the if condition limits the job to the main branch, and because secrets are not available to forks. That is the intended behaviour: the pull request verifies the build, it does not publish.

setup-node cache or actions/cache?

setup-node with cache: npm covers the normal case in two lines. actions/cache fits when you must keep a folder setup-node does not know about, such as a browser or a binary you download by hand.

Can I start the workflow by hand?

Yes, by adding workflow_dispatch to the triggers. From the terminal the command is gh workflow run ci.yml, and gh run watch follows the execution.