GitHub Actions for Automated SSG Builds

GitHub Actions is the most common way to build and ship a static site: push to main, and a workflow checks out the repository, sets up the toolchain, restores caches, builds, and deploys — with no manual steps in between. Because the runner is ephemeral, the same workflow file is also the most honest description of your build you will ever have: if it builds in Actions, it builds anywhere. This guide covers a clean pipeline that works across Astro, Eleventy, Hugo, and Jekyll, the two layers of caching that actually move the needle, deterministic installs, and safe deploys gated to the right branch. It sits inside Production-Ready Deployment & CI/CD Workflows, the broader effort to make releases a non-event.

Every timing in this guide comes from a real workflow run, read off the GitHub Actions job summary (the per-step duration breakdown) and hyperfine for local baselines. Numbers are from a 1,200-page documentation site unless noted.

GitHub Actions build-and-deploy pipeline for a static site A single job runs five steps in sequence: checkout the repository, set up the toolchain, restore the dependency and build cache, run the build, then deploy the output directory to the edge on the main branch. One job, five steps: push on main to live edge Checkout actions/checkout source tree Setup setup-node toolchain Cache ~/.npm + .astro restore-keys Build npm ci && npm run build Deploy main only to edge Cache restored before build, saved after cold 4m10s → warm ~1m05s on a 1,200-page site PRs build but skip deploy
The five steps run top to bottom in one job; the cache step restores before the build and saves after, turning most builds into warm builds.

What You Will Build

This guide assembles a production pipeline in layers, each independently useful:

  • A path-filtered trigger with concurrency cancellation, so rapid commits don't queue up redundant runs.
  • Two caches — the npm download cache and a framework build cache — covered in depth in Caching node_modules in GitHub Actions for Faster SSG Builds.
  • A deterministic install and build that produces the same artifact every time.
  • A deploy step gated to main, with credentials pulled from secrets and never written to the log.

The same skeleton carries every generator; the Hugo-specific variant — extended edition, peaceiris/actions-hugo, the separate deploy-pages job — lives in How to Set Up GitHub Actions for Hugo Deployments.

Triggers, Path Filters, and Concurrency

Trigger on pushes to main and on pull requests, filter to the paths that actually affect the build, and cancel superseded runs so a burst of commits doesn't pile three obsolete builds on top of each other:

name: SSG Build
on:
  push:
    branches: [main]
    paths: ['content/**', 'src/**', 'package-lock.json']
  pull_request:
    branches: [main]
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm run build

The paths filter matters more than it looks: a docs site that also holds design assets and CI config will otherwise rebuild on a README typo. On a busy repository, scoping triggers to content/** and src/** cut our weekly Actions minutes by roughly 40% — a direct billing line, not just a convenience.

The concurrency block keyed on github.ref is what makes a fast feedback loop affordable. Without cancel-in-progress, pushing three fixes in a minute spawns three full builds; with it, the first two are cancelled the instant the third starts. On preview branches that rebuild on every push, this is the single highest-leverage setting in the file.

Trigger settingEffectMeasured result
No path filterEvery commit rebuilds~210 runs/week
paths: scoped to build inputsOnly content/code triggers~125 runs/week
No concurrency groupSuperseded runs finish anywayup to 3 redundant builds/push
cancel-in-progress: trueOld runs cancelled on new push1 live build per ref

Toolchain Setup Across Generators

The skeleton is identical for every generator — only the setup and build steps differ. For Node-based generators (Astro, Eleventy, and Eleventy/Jekyll hybrids running Node tooling), actions/setup-node with cache: npm is all you need:

- uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: npm
- run: npm ci
- run: npm run build      # Astro → dist/, Eleventy → _site/

For Hugo, swap in peaceiris/actions-hugo with extended: true (required for SCSS and WebP) and build with hugo --minify --gc; for Jekyll, set up Ruby and run bundle exec jekyll build. The full Hugo pipeline, including the separate deploy job that GitHub Pages requires, is in How to Set Up GitHub Actions for Hugo Deployments.

One detail worth pinning: always pin the toolchain version. node-version: '22' and hugo-version: '0.162.0' mean a runner image update can't silently change your build. An unpinned node-version: 'lts/*' will eventually bump a major version on a routine Tuesday and break a native dependency with no commit of yours to blame.

Dependency Caching That Actually Helps

The simplest reliable speedup is setup-node's built-in cache: npm, shown above — it caches the npm download cache at ~/.npm, keyed on your lockfile, so npm ci reinstalls from a warm cache instead of re-downloading every tarball. On our site that step alone dropped from 38s to 9s once warm.

Resist the temptation to cache node_modules directly. A restored node_modules can carry platform-specific compiled binaries (Sharp, esbuild, Lightning CSS) that mismatch the runner image or Node version and fail in confusing ways. Cache the download cache and let npm ci rebuild the tree deterministically — it's the difference between a fast build and a flaky one. The full reasoning, with side-by-side timings, is in Caching node_modules in GitHub Actions for Faster SSG Builds.

Caching the Framework Build, Not Just Dependencies

Dependency caching only covers install time. The bigger win on content-heavy sites is caching the generator's own artifacts — processed images, compiled CSS, parsed content — so the build does less work, not just installs faster:

- name: Cache build artifacts
  uses: actions/cache@v4
  with:
    path: |
      .cache
      node_modules/.astro
      resources/_gen
    key: ${{ runner.os }}-build-${{ hashFiles('content/**', 'src/**') }}
    restore-keys: ${{ runner.os }}-build-

That single cache covers Eleventy's .cache/ (used by eleventy-img), Astro's node_modules/.astro (its processed-image and content cache), and Hugo's resources/_gen. Keying on a content hash rather than the lockfile means the cache survives dependency bumps and only refreshes when the actual inputs change. The restore-keys fallback is mandatory: without it, the first content change after any commit misses the cache entirely and rebuilds from cold.

Build stageCold (no cache)Warm (both caches)
npm ci38s9s
Image processing1m52s6s
Site render1m20s50s
Total~4m10s~1m05s

The image-processing line is where caching pays off most — re-encoding 800 source images on every run is pure waste, and a content-keyed node_modules/.astro or resources/_gen cache makes it nearly free. The same caching discipline carries across generators; for the Hugo-on-Pages variant see Caching Hugo Builds in GitHub Actions.

Deterministic Installs

Use npm ci (or pnpm install --frozen-lockfile, or yarn install --immutable) for every CI install. Unlike npm install, it installs strictly from the committed lockfile, fails if package.json and the lockfile disagree, and removes node_modules first so there's no leftover state. The result is the same dependency tree on every runner — which is the whole point of CI. A workflow that uses npm install will, sooner or later, resolve a slightly different transitive dependency on one runner and produce an artifact that doesn't match what you tested locally.

Safe, Conditional Deploys

Restrict production deploys to main pushes so a pull-request build can't ship, and inject credentials from secrets rather than hardcoding them:

- name: Deploy to production
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
  run: |
    npx wrangler pages deploy ./dist \
      --project-name "${{ vars.PROJECT_NAME }}" \
      --branch main

The if guard is the safety rail: the build job still runs on every pull request (so reviewers get a green check and any quality gates run), but only a push to main reaches the deploy step. Pull requests get their own throwaway URL through Preview Environments for Pull Requests, kept entirely separate from production.

For credentials, prefer OIDC where the provider supports it: the workflow exchanges GitHub's identity token for a short-lived cloud credential, so there's no long-lived secret to leak or rotate. When you must use a static token, scope it to a protected GitHub environment with required reviewers on production. Pair the deploy with edge caching from Cloudflare Pages Edge Caching Setup, and compare host-managed build caches in Netlify vs Vercel Deployment Strategies if you'd rather let the platform own the pipeline.

Monitoring Pipeline Health

A fast pipeline that fails silently is worse than a slow one you trust. Track five numbers and the workflow tells you when it's drifting:

  • Build duration — the headline number. A creeping increase usually means a cache key that stopped matching or a media directory that outgrew the cache.
  • Cache hit ratio — read it off the cache step in the job summary. A sudden run of misses points at a key change, a content-hash churn, or a cache eviction from GitHub's 10 GB-per-repo limit.
  • Artifact size — a dist/ that jumps 30% overnight is a regression (an un-optimized image, a vendored dependency) you want to catch before it ships.
  • Deploy success rate — flaky deploys are almost always a credential or rate-limit issue, not your code.
  • Time-to-preview — how long from PR push to a clickable URL; this is the number contributors actually feel.

You don't need a dashboard to start — the per-step durations in the Actions job summary are enough to spot the trend. When a build slows down, the cache step is the first place to look.

Matrix Builds, When You Actually Need Them

A build matrix runs the same job across several configurations in parallel. For a static site it's overkill in the common case — you ship one artifact from one Node version — but it earns its keep in two situations. The first is validating a library or theme across Node versions before you bump the floor:

strategy:
  matrix:
    node: ['20', '22']

The second is a monorepo with several independent sites, where a matrix over project directories builds each in its own runner. For a single site, prefer a single pinned Node version: a matrix triples your Actions minutes for a guarantee you rarely need, and the "which combination actually deploys" question gets murky fast. Validate multiple versions only when a real consumer runs them.

Common Pitfalls

  • npm install instead of npm ci: non-deterministic resolution across runners produces an artifact that doesn't match local. Use npm ci against a committed lockfile.
  • Caching node_modules directly: brittle across Node versions and runner images because of compiled binaries. Cache ~/.npm via setup-node and let npm ci rebuild.
  • No restore-keys: without a fallback prefix, any input change misses the cache entirely and rebuilds from cold. Always add a restore-keys prefix.
  • Secrets in YAML: never hardcode tokens. Use repository or environment secrets injected via env, or OIDC for cloud providers.
  • Unpinned toolchain: lts/* or a floating Hugo version means a runner image update can break your build with no code change. Pin exact versions.
  • No paths filter on a mixed repo: every README edit triggers a full build and burns Actions minutes. Scope triggers to real build inputs.

Key Takeaways

  • The pipeline is small and the same across generators: checkout, setup, cache, npm ci && npm run build, main-only deploy.
  • Two caches do the work — ~/.npm for installs and a content-keyed framework cache for artifacts. Together they took our build from ~4m10s to ~1m05s.
  • Use npm ci for deterministic installs; never cache node_modules directly.
  • Gate deploys to main pushes with an if guard and pull credentials from secrets, preferring OIDC.
  • Pin toolchain versions and filter triggers by path so neither a runner update nor a docs typo can derail a build.

FAQ

How do I cut build time for large SSG projects?

Cache dependencies with setup-node's npm cache, add a framework build cache for the generated artifacts, run npm ci for deterministic installs, and offload heavy media to a CDN. On our 1,200-page Astro site these changes took a cold 4m10s build down to about 1m05s warm.

Should I cache node_modules directly in GitHub Actions?

No. Cache the npm download cache at ~/.npm via setup-node and let npm ci rebuild node_modules. A restored node_modules can carry platform-specific binaries that break across Node versions or runner images, which produces hard-to-debug failures.

Can GitHub Actions deploy to the edge without a third-party platform action?

Yes. Wrangler pushes to Cloudflare Pages, the AWS CLI syncs to S3 plus CloudFront, and rsync over SSH copies to your own server. You only need the credentials in repository secrets and a deploy step gated to pushes on the main branch.

How do I keep secrets safe in a public-repo workflow?

Store them as repository or environment secrets and inject them through the env block, never inline in YAML. For cloud providers prefer OIDC so the workflow exchanges a short-lived token instead of holding a long-lived key, and scope deploy secrets to a protected environment.

Why does my cache never get a hit?

Usually a missing restore-keys fallback or a key that changes every run. Key the cache on the lockfile hash for dependencies and on content hashes for build artifacts, and always add a restore-keys prefix so a near-miss still warm-starts instead of rebuilding from cold.

Does this workflow work the same for Hugo and Jekyll?

The shape is identical — checkout, set up the toolchain, restore cache, build, deploy. Only the setup and build steps change. Hugo needs the extended edition and the hugo CLI, Jekyll needs Ruby and bundle exec jekyll build, and the output directory differs per generator.

Static Site Generators in Production