Building Astro Sites with GitHub Actions

Astro builds static sites quickly on a laptop, but a naive CI workflow can take ten minutes: a fresh npm install every run, every image re-encoded from scratch, and no feedback until the whole build finishes. A well-structured GitHub Actions workflow builds the same site in a fraction of the time, catches type errors before the build, produces an artifact that previews and deploys share, and keeps deployment credentials away from pull requests.

This guide builds that workflow step by step for an Astro 5 site, with caching for dependencies and Astro's asset cache, astro check, build artifacts, and a separate deploy job. It is part of GitHub Actions for Automated SSG Builds.

Prerequisites

  • An Astro 4 or 5 project in a GitHub repository.
  • A lockfile committed (package-lock.json, pnpm-lock.yaml or yarn.lock).
  • A deploy target and its credentials — Cloudflare, Netlify, Vercel, S3 or GitHub Pages.

The Shape of the Workflow

Two jobs, one artifact. The build job runs on pull requests and pushes; the deploy job runs only on main and consumes the build's output.

Build and deploy jobs for an Astro site The build job checks out the code, sets up Node with the npm cache, restores the Astro cache, installs dependencies, runs astro check and astro build, and uploads the dist folder as an artifact. The deploy job runs only on main, downloads the artifact and deploys it using secrets from a protected environment. One build, one artifact, one gated deploy build · every PR and push 1. checkout 2. setup-node (npm cache) 3. restore node_modules/.astro 4. npm ci 5. astro check 6. astro build 7. upload dist artifact permissions: contents read · no deploy secrets deploy · main only download artifact deploy with env secrets environment: production
The deploy job never builds; it ships exactly what the build job tested.

The Build Job

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

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

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

      - uses: actions/cache@v4
        with:
          path: node_modules/.astro
          key: astro-${{ runner.os }}-${{ hashFiles('package-lock.json', 'astro.config.*') }}-${{ github.sha }}
          restore-keys: |
            astro-${{ runner.os }}-${{ hashFiles('package-lock.json', 'astro.config.*') }}-
            astro-${{ runner.os }}-

      - run: npm ci
      - run: npx astro check
      - run: npm run build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist
          retention-days: 7

A few details matter:

  • concurrency with cancel-in-progress stops superseded runs when someone pushes twice in quick succession, which saves minutes and avoids deploying an older commit after a newer one.
  • node-version-file reads the Node version from .nvmrc, so CI and local development match.
  • cache: npm caches npm's download cache, not node_modules, which is the approach npm recommends with npm ci. See Caching node_modules in GitHub Actions for the trade-offs.
  • The Astro cache key includes the commit SHA so every run saves a fresh cache, while restore-keys restore the most recent one for the same dependencies and config.

Why the Astro Cache Matters

Astro stores optimised images and content layer data in node_modules/.astro. On a site with many images, encoding them is by far the slowest part of the build, and without the cache every CI run re-encodes every image even if none changed.

Workflow duration with and without caches For a 1,800-page Astro site with 2,600 images: no caches takes 9 minutes 20 seconds; npm cache only takes 8 minutes 10 seconds; npm and Astro caches take 2 minutes 5 seconds. Build job duration, 1,800 pages, 2,600 images no caches 9:20 npm cache only 8:10 npm + Astro cache 2:05 ubuntu-latest runner; typical content-only pull request
Dependency caching saves a minute; the Astro asset cache saves seven.

If you use the content layer with remote loaders — a headless CMS, for example — the same cache holds the loaded data and digest, so unchanged entries are not re-fetched or re-rendered. More on this in Incremental Builds in Astro with the Content Layer.

Running astro check

astro check type-checks .astro components, TypeScript files and content collection schemas. A frontmatter field with the wrong type, a missing required prop or a typo in a collection name fails here with a clear message, rather than as an obscure build error or, worse, a page that builds with missing data. Running it as its own step makes the failure visible in the job summary.

Install @astrojs/check and typescript as dev dependencies so the command does not prompt in CI.

Build-Time Secrets and Environment Variables

Some builds need credentials of their own: a headless CMS token for the content layer, an API key for a search index, or a site URL that differs between preview and production. Treat these differently from deploy credentials.

Read-only content tokens can be stored as repository secrets and passed to the build step with env:. They are still unavailable to pull requests from forks, which is usually what you want; for fork contributions, build against a public preview API or skip the remote content. Values that are not secret, such as SITE_URL or a feature flag, belong in repository variables (vars.SITE_URL) so they are visible in logs and easy to change.

Astro exposes variables prefixed with PUBLIC_ to client code, so never give a secret that prefix. Astro 5's astro:env schema lets you declare which variables are server-only, client-visible or secret, and fails the build if a required one is missing — a clearer error than an empty page. Declare the schema in astro.config.mjs and the CI step fails fast when a secret was not configured for a new environment.

Debugging a Slow Build

When a build suddenly gets slower, check the cache step's log first: "Cache not found" or a restore from an older key usually explains it. Then add --verbose to astro build for one run to see per-page and per-image timings, and compare the job's step durations with a previous run in the Actions UI.

The Deploy Job

  deploy:
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }
      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          command: deploy

The environment: production line is the security boundary. Secrets stored in the environment are only available to jobs that reference it, and environment protection rules can require a reviewer or limit deployments to main. Pull requests from forks never receive them. For cloud providers that support it, replace the long-lived token with OIDC; see Securing Deploy Credentials with GitHub OIDC.

Swap the deploy step for your host: netlify deploy --prod --dir dist, vercel deploy --prebuilt --prod, aws s3 sync dist s3://bucket --delete, or the GitHub Pages actions described in Deploying to GitHub Pages with Actions.

Adding Checks Without Slowing the Build

Once the build artifact exists, other jobs can consume it in parallel instead of rebuilding: a link check, a Lighthouse CI run, a bundle size report. Each downloads dist and runs its check, and the deploy job can list them in needs so a failure blocks deployment.

Parallel check jobs sharing one build artifact The build job produces a dist artifact. Three jobs run in parallel from it: link check, Lighthouse CI and bundle size. The deploy job needs all three to pass. Build once, check in parallel build → dist link check Lighthouse CI bundle size deploy (main) total time ≈ build + slowest check, not the sum of all checks
Checks add parallel width, not sequential length.

See Checking Links in Pull Requests and Setting Up Lighthouse CI for a Static Site for the individual checks.

Measured Impact

A 1,800-page Astro documentation site with 2,600 images moved from a single job that installed, built and deployed on every push to the structure above. Typical pull request builds fell from 9 minutes 20 seconds to 2 minutes 5 seconds, almost entirely from restoring the Astro cache. astro check caught 14 content schema errors in the first month that previously produced pages with empty fields, and deploy credentials were removed from every job except the production deploy.

Pitfalls & Rollback

  • Caching node_modules directly with npm ci. npm ci deletes it anyway; cache the npm cache instead.
  • A static Astro cache key. A key without the SHA is saved once and never updated; include the SHA and use restore keys.
  • Deploying from the build job. Secrets then reach pull request runs; keep deploy separate.
  • Unpinned Node. A runner image update can change Node's version under you; pin it.
  • Rollback: the previous workflow file is in Git history; revert it in one commit.

Conclusion

A good Astro workflow builds once, checks the result in parallel and deploys exactly what was tested. Pin Node, cache npm's downloads and Astro's node_modules/.astro directory, run astro check before the build, upload dist as an artifact, and deploy from a separate job gated to main and a protected environment. Most sites see builds drop to a couple of minutes and deploy secrets disappear from pull requests.

FAQ

What should an Astro CI workflow cache?

The package manager cache through actions/setup-node, and Astro's own cache directory, node_modules/.astro, which stores optimised images and content layer data. Restoring the Astro cache avoids re-encoding every image on each build, which is often the slowest step.

Should I run astro check in CI?

Yes. astro check type-checks .astro files, content collection schemas and TypeScript, catching errors that a build may not. Run it as a separate step before the build so failures are reported clearly.

How do I keep deploy credentials away from pull requests?

Split the workflow into a build job that runs on every pull request with read-only permissions, and a deploy job that runs only on pushes to main, in a protected environment that holds the secrets. Pull requests from forks never receive those secrets.

Which Node.js version should I use for Astro builds?

The version Astro's current major release supports, pinned in a .nvmrc or package.json engines field and read by actions/setup-node with node-version-file. Pinning keeps CI and local builds identical and makes upgrades deliberate.