Production-Ready Deployment & CI/CD Workflows

Deploying a static site reliably comes down to two disciplines: builds that produce the same artifact every time, and deploys that flip atomically so a half-finished release never reaches users. Get those right and "deploy" stops being an event you brace for and becomes something you do many times a day without thinking about it. This guide is for engineers and documentation teams who already ship a static site and want a pipeline that is reproducible, reviewable, and instantly reversible.

The patterns here apply across Astro, Eleventy, Hugo, Jekyll, and a Next.js static export — only the build output directory and a few host flags differ. We move through the full release lifecycle in the order it actually runs: commit, build, preview deploy, automated checks, promotion to production, and rollback when something slips through. Every stage ties back to a concrete host behavior and a command you can run to verify it.

The deploy lifecycle: commit to rollback across hosts A pipeline showing commit, build, preview deploy, automated checks, promotion to production, and rollback, with the hosts that handle each stage: GitHub Actions for build and checks, and Cloudflare Pages, Netlify, and Vercel for preview, promotion, and rollback. One commit moves through six stages before it is live — and can reverse in one Commit push to branch Build npm ci + build cached deps Preview per-PR URL isolated deploy Checks links · a11y Lighthouse Promote atomic flip to production Rollback re-point to prev build one-step reverse GitHub Actions build & checks · shared cache · matrix Cloudflare Pages · Netlify · Vercel preview · promote · rollback · edge cache
GitHub Actions owns build and checks; the host platforms own preview, promotion, and the one-step rollback. Each stage produces an immutable artifact the next stage consumes.

What You Will Learn

This guide is organized around the deploy lifecycle, and each stage has its own deep-dive section you can follow when you implement it:

This work sits alongside the other two halves of running a static site in production: Choosing the Right Static Site Generator for Production covers the framework decision that shapes your build, and Performance Optimization & Core Web Vitals for SSGs covers the runtime metrics your deploy pipeline is ultimately protecting.

Two capabilities the pipeline needs from day one

Two of the sections below are worth naming up front because they are usually retrofitted after an incident. The first is a rehearsed way to put the previous build back: atomic promotion, retained artifacts and a rollback that is a pointer change rather than a rebuild — the subject of Rollbacks and Deploy Safety for Static Sites. The second is a path for the people who write the content, since a pipeline only non-engineers cannot use quietly becomes a bottleneck; that is Content Workflows for Documentation Teams.

Choosing a Host for Your SSG

The framework you picked dictates the build; the host dictates routing, edge-compute limits, preview ergonomics, and cost. Every major host can serve pre-rendered HTML quickly, so the decision is about everything around the artifact.

Cloudflare Pages leans on the largest edge network and an unmetered bandwidth model, which makes it the cheapest choice for high-traffic content sites; its _headers and _redirects files keep cache and routing in version control, and its Workers layer is there when a route genuinely needs compute — the specifics are in Deploying Hugo to Cloudflare Pages and Workers. Netlify pioneered the deploy-preview-per-PR workflow and has the most polished build plugins and forms/redirects ergonomics. Vercel is the natural home for a Next.js static export and offers incremental static regeneration when you have a handful of pages that genuinely need to revalidate — the line between "regenerate" and "rebuild" is drawn in Vercel ISR vs Static Generation for SSGs. The detailed trade-off across all three lives in Netlify vs Vercel Deployment Strategies.

There are really two host models, and picking between them is the first architectural fork. In the build-on-host model you connect a Git repository and the platform runs its own build container on every push — the simplest possible workflow, and the right default when the host's native build covers your needs. In the build-in-CI model you build the artifact yourself and hand the finished output to the host to serve, which you reach for when you need custom asset processing, matrix testing across Node versions, or a shared cache spanning multiple jobs. A useful rule: pick the host whose native build covers 90% of your needs, then move only the last 10% into GitHub Actions for Automated SSG Builds. Whichever model you choose, keep the custom domain, DNS, and TLS on the host so certificate renewal and edge routing are one system rather than three.

Reproducible Build Pipelines

A build is reproducible when the same commit produces a byte-identical artifact on any runner. That starts with installing from the lockfile, never the loose package.json range, and pinning the runtime so a host's default Node version cannot silently change your output.

name: Deploy SSG
on:
  push:
    branches: [main]
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 && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: ./dist

npm ci installs exactly what the lockfile specifies for a deterministic dependency tree, and the uploaded artifact is handed to a separate deploy job so build and deploy are decoupled. Set path to your generator's output directory: Astro emits dist, Hugo emits public, and Eleventy and Jekyll both default to _site. The end-to-end Cloudflare example is in Automating Eleventy Deployments with Cloudflare Pages, and the Hugo variant in How to Set Up GitHub Actions for Hugo Deployments.

Pinning Node is only the first pin. Commit an .nvmrc (or an engines field in package.json) so local, CI, and the host all resolve the same major version rather than the runner's floating default, and pin the generator itself in the lockfile so a background latest never changes your HTML mid-week. The subtler sources of non-determinism are environmental: a build that stamps new Date() into a footer, sorts a collection by an unstable key, or reads the runner's locale and timezone will produce a different artifact on every run even from an identical commit. Freeze those inputs — inject a fixed build timestamp from the commit, sort by an explicit stable field, and set TZ=UTC and LANG=C.UTF-8 in the job — so "same commit, same bytes" holds in practice and not just in principle. The payoff is concrete: when two builds of the same commit are byte-identical, a failed deploy can be diagnosed by comparing artifacts instead of guessing at the runner.

Faster Builds: Incremental Compilation & Caching

The single biggest threat to a pleasant pipeline is build time growing with the site. A 200-page site that builds in 20 seconds becomes a 2,000-page site that builds in four minutes, and suddenly every preview deploy is a coffee break. Two techniques keep it flat: incremental builds that only re-render changed pages, and a CI cache that survives between runs so dependencies and processed assets are not recomputed.

# Restore the generator's build cache between runs
- uses: actions/cache@v4
  with:
    path: |
      node_modules/.cache
      .eleventy-cache
    key: ${{ runner.os }}-ssg-${{ hashFiles('package-lock.json') }}
    restore-keys: ${{ runner.os }}-ssg-

A warm cache routinely turns a 90-second cold build into a 15-second incremental one. The key binds the cache to the exact lockfile so a dependency change busts it automatically, while restore-keys lets a near-miss fall back to the most recent compatible cache instead of starting cold — the discipline that keeps this from silently serving stale output is covered in Caching node_modules in GitHub Actions for Faster SSG Builds. The full set of techniques — per-generator incremental flags, cache-key hygiene, and sharing a cache across CI runners so a matrix build does not each pay the cold-start tax — is in Incremental Builds and Build Caching for SSGs.

Content Workflows & Build Triggers

A commit is not the only thing that should ship a site. Content-heavy projects publish from people who never touch Git — writers in a headless CMS, an editor approving a scheduled post — and the pipeline has to react to those events without a developer in the loop. The mechanism is a build hook: a secret webhook URL that starts a production build when something outside the repository changes. A CMS calls it on publish, a cron schedule calls it to pick up time-based content, and an external data source calls it when its feed updates. The end-to-end wiring is in Netlify Build Hooks for Content Updates.

# Trigger a production build from a CMS publish webhook
curl -X POST -d '{}' https://api.netlify.com/build_hooks/YOUR_HOOK_ID

Treat the hook URL as a secret — anyone holding it can spend your build minutes — and debounce it, because a bulk edit that fires the hook fifty times should coalesce into one build, not fifty. The harder question is genuinely dynamic data: a price, a stock count, a comment thread. Keep it out of the build so the artifact stays deterministic. Push it to an edge or serverless function, fetch it from a cached API on the client, or use incremental regeneration on the handful of routes that truly need it — the trade-off between regenerating and rebuilding is drawn in Vercel ISR vs Static Generation for SSGs. The rule of thumb: if data changes on a schedule you control, rebuild on that schedule; if it changes per request, serve it at the edge and leave the static build alone.

Preview Environments for Every Pull Request

The cheapest place to catch a broken link, a rendering error, or a Core Web Vitals regression is a preview URL, not production. Every managed host can build a pull request into an isolated, fully addressable deploy with its own subdomain, and you should require that preview to pass before merge.

A preview deploy is where your automated checks belong — link checking, accessibility audits, and a Lighthouse budget all run against the real deployed URL rather than a local guess:

lhci autorun \
  --collect.url=https://deploy-preview-128--your-site.netlify.app/ \
  --assert.preset=lighthouse:recommended

When the check fails, the pull request is blocked and nothing reaches production. The mechanics of wiring previews into branch protection, and tearing them down to control cost, are covered in Preview Environments for Pull Requests; if your host does not build previews natively, Automating Preview Deploy Pipelines with GitHub Actions shows how to stand one up and post the URL back to the PR.

Edge Delivery, Caching & Routing

Serving pre-built HTML well is mostly about cache headers and keeping routing in version control. Distribute through a CDN's points of presence to cut Time to First Byte, and use the two-tier cache policy so a deploy does not stampede your origin: fingerprinted assets are immutable for a year, HTML is short-lived.

/assets/*
  Cache-Control: public, max-age=31536000, immutable
/*.html
  Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400

s-maxage controls the shared edge cache while max-age=0 keeps the browser revalidating, and stale-while-revalidate lets the edge serve a slightly stale page while it refreshes in the background. This is safe only because SSGs emit content-hashed asset filenames, so caching the old URLs forever can never serve a wrong asset. The host-specific syntax and purge automation are in Cloudflare Pages Edge Caching Setup.

Keep routing and security headers next to the site, not in a dashboard, so they are reviewable. A Netlify example:

# netlify.toml
[[redirects]]
  from = "/blog/*"
  to = "/posts/:splat"
  status = 301
  force = true

[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-Content-Type-Options = "nosniff"

Use explicit 301 redirects so existing inbound links and bookmarks keep working through URL changes, and set security headers at the edge so every route gets them.

Security & Compliance Hardening

Static output has a small attack surface, but the build pipeline does not. Treat build-time secrets carefully: anything injected into client-visible files — for example an Astro variable with the PUBLIC_ prefix — ships to the browser. Keep tokens server-side, scope each one to the minimum the build needs, and never give a secret a client-exposed prefix.

At the edge, enforce a Content Security Policy alongside X-Frame-Options and X-Content-Type-Options. For third-party scripts you cannot drop, add Subresource Integrity hashes so a compromised CDN file fails closed instead of executing. The CI tokens that drive cache purges and deploys deserve the same discipline — scope a Cloudflare token to Zone → Cache Purge only, rather than handing it account-wide rights.

The build itself is a supply chain, so treat it like one. Every build-time dependency runs with access to your repository, your environment, and the output directory, which means an abandoned plugin or a typo-squatted package is code execution, not a hypothetical. Run npm audit --audit-level=high as a required CI step so a newly disclosed vulnerability fails the build instead of shipping, pin dependencies in the lockfile so an upgrade is a reviewable commit rather than a surprise, and prefer first-party plugins in the critical path because they track the framework's release cadence. Because the build runs unattended on a shared runner, give the job the narrowest token scope it can function with and rotate anything long-lived on a schedule.

Promotion & Atomic Deploys

Atomic deploys are the foundation of fast, safe releases. Each build becomes an immutable, versioned artifact, and promotion swaps the live version in a single pointer flip — visitors mid-request either get the old version fully or the new version fully, never a mix. There is no window where half the assets are updated.

Atomic promotion and rollback are the same pointer flip Three immutable versioned build artifacts, v1, v2, and v3, sit side by side. A single LIVE pointer currently aims at v3 and serves production. A dashed re-point arrow shows the same LIVE pointer moving back to v2 to roll back, illustrating that both promote and rollback are one-step re-points at a kept build, never a rebuild. Promotion and rollback are the same move: flip the LIVE pointer Every build is kept immutable — the pointer moves, nothing is rebuilt in place Build v1 sha 3b7e55 immutable · verified Build v2 sha 6d2af0 immutable · verified Build v3 sha 9f3c1a immutable · verified roll back = one re-point LIVE serves production LIVE after rollback Promote a newer build or roll back to an older one — both are the identical flip, never a rebuild
Each build is an immutable, versioned artifact that is never mutated in place. Promotion and rollback are the identical operation — re-point LIVE at a different build — which is why a rollback takes seconds and never triggers a fresh build.

The mechanism underneath is blue-green: the new version is uploaded and warmed completely before any traffic moves to it, then the edge's routing pointer flips from the old version to the new one in a single operation. Nothing is mutated in place, so the previous version keeps existing exactly as it was — which is what makes rollback a re-point rather than a rebuild. This is only true end to end if the artifact you promote is the exact one your checks ran against on the preview URL; rebuilding at promotion time reintroduces the non-determinism you worked to eliminate and means the thing you tested is not the thing you shipped.

Promotion strategy is usually one of three: deploy straight to production on merge to main for fast-moving content sites, promote a previously built preview to production for teams that want a manual gate, or use a release branch that production tracks. The first optimizes for velocity and leans entirely on your preview checks to catch regressions; the second adds a human approval step at the cost of a slower path to live; the third suits teams coordinating a release across several repositories. All three preserve atomicity as long as promotion is a pointer flip on a pre-built artifact and never a fresh build against production.

Rollback & Incident Response

Because each release is an immutable artifact, rolling back is just re-pointing at the previous build, which Cloudflare Pages, Netlify, and Vercel all do in seconds from the dashboard or a single CLI command. There is no partial state to clean up.

The cache policy is what makes instant rollback actually work. If HTML is cached long, users keep seeing the old release after you roll back, because their browser never revalidates:

/*.html
  Cache-Control: public, max-age=0, must-revalidate
/assets/*
  Cache-Control: public, max-age=31536000, immutable

Watch build logs, CDN error rates, and synthetic uptime checks so alerts fire before users feel an incident, and keep a short runbook for cache purges and DNS failover. When a Core Web Vital drops in field data, line it up against your deploy timeline — a sudden regression almost always maps to a specific release, which the Performance Optimization & Core Web Vitals for SSGs guide explains how to read.

Common Pitfalls

  • Cache stampede on deploy: invalidating the entire CDN at once can hammer your origin. Use atomic deploys plus stale-while-revalidate so the edge serves slightly-stale content while it revalidates in the background.
  • Environment variable leakage: secrets injected into client bundles are public. Keep them server-side and never give a secret a client-exposed prefix.
  • Long TTLs on HTML: caching HTML aggressively serves stale content and silently breaks rollbacks. Keep HTML short-lived; reserve immutable for hashed assets.
  • Rebuilding at promotion time: building again to promote reintroduces non-determinism. Promote the exact artifact that passed your checks.
  • Broken caches across runners: a stale or corrupted CI cache produces missing pages. Use explicit cache keys tied to the lockfile and validate the output with a link check before promoting.
What a production static pipeline owes you Five properties of a production-ready pipeline: reproducible builds, gated content, a preview per change, an atomic promotion and a fast rollback. Each band names the failure it prevents. What a production static pipeline owes you Reproducible build pinned toolchain and lockfile — the same commit builds the same bytes Gated content links, schema and budgets fail the pull request, not the reader Preview per change the rendered page is the review artifact Atomic promotion readers see the whole old version or the whole new one Fast rollback a pointer flip, with no rebuild in the critical path A pipeline missing any one of these has a specific, predictable bad day associated with it.
Each property is independently cheap on static hosting; together they are what makes deploying boring.

The five are also a useful maturity order. Reproducibility comes first because nothing downstream is trustworthy without it; gates come next because they are the cheapest place to catch a problem; previews change how review works; and the last two are what turn a bad deploy from an incident into an inconvenience.

A pipeline is also a document. Anyone joining the team should be able to read the workflow file and know how a change reaches production, which environments exist, what can block a deploy and how to reverse one. When that is not readable from the repository, the knowledge lives in one person's head — which is fine until the week they are away and something breaks.

Key Takeaways

  • Reproducible builds start with npm ci and a pinned runtime — the same commit must produce the same artifact anywhere.
  • Give every pull request a preview deploy and run your link, accessibility, and performance checks against it before merge.
  • Promote the exact artifact that passed your checks; never rebuild at promotion time.
  • Use the two-tier cache policy — immutable hashed assets, short-lived HTML — so rollback is instant and repeat visits are free.
  • Keep routing, headers, and secrets in version control and scoped to the minimum, so deploys are reviewable and reversible.

FAQ

How do I handle dynamic content in a static CI/CD pipeline?

Decouple it from the build. Push genuinely dynamic data to edge or serverless functions, use incremental regeneration where your host supports it, or fetch from a cached API on the client. The static build stays deterministic while the dynamic parts live at the edge.

What is the optimal cache TTL for SSG deployments?

Use a two-tier policy. Cache fingerprinted assets for one year as immutable, and keep HTML short-lived with max-age=0 or a small s-maxage paired with stale-while-revalidate. This keeps rollbacks instant while making repeat visits nearly free.

How can I prevent broken builds from reaching production?

Gate merges on branch protection, a required preview deploy, automated link checking, and a performance budget. Run those checks on the pull request so a broken build fails before it ever promotes to the production branch.

What makes a deploy atomic and why does it matter?

An atomic deploy publishes an immutable versioned artifact and flips the live pointer in one step, so users never see a half-written release. It also makes rollback a one-step re-point at the previous version instead of a cleanup operation.

Should I build on my host or in GitHub Actions?

Build in GitHub Actions when you need custom steps, matrix testing, or shared caching across jobs, then deploy the artifact. Build on the host when you want the simplest possible Git-push workflow and the host's native build covers your needs.

How fast can I roll back a bad release?

On Cloudflare Pages, Netlify, and Vercel a rollback is selecting a previous deployment or running one CLI command, and it takes seconds because each deploy is an immutable artifact. The only thing that slows it down is HTML cached too aggressively.