Automating Eleventy Deployments with Cloudflare Pages

Cloudflare Pages can build and deploy an Eleventy site straight from Git: connect the repository once, set the build command and output directory, and every push ships to the global edge. There is no CLI upload step, no artifact to move, and no separate hosting account to reconcile — the connected repository is the deploy pipeline. This recipe covers that setup end to end: connecting the repo, pinning the Node runtime, wiring environment variables, shipping cache headers, adding preview builds, and mixing in Pages Functions — plus the actual build and deploy times you should expect. It is the concrete, Eleventy-specific application of the caching and delivery model described in Cloudflare Pages Edge Caching Setup.

Prerequisites

  • An Eleventy 3 site in a GitHub or GitLab repository, with a committed package-lock.json.
  • A Cloudflare account with access to Workers & Pages.
  • curl available locally to inspect response headers against the deployed URL.
Eleventy to Cloudflare Pages continuous deploy flow A push to the repository triggers Cloudflare Pages to run npm ci then the Eleventy build, producing the _site output with a _headers file, which deploys atomically to the edge. Git push is the deploy pipeline git push to main / PR npm ci from lockfile Node 22 pinned eleventy build _site/ output + _headers Atomic deploy to global edge preview or prod No CLI, no artifact upload — Pages owns build and deploy from the connected repo.
A push triggers `npm ci`, the Eleventy build into `_site/`, and an atomic deploy to the edge — production on `main`, an isolated preview URL on every other branch.

Connect the Repo & Configure the Build

In the Cloudflare dashboard, go to Workers & Pages → Create → Pages → Connect to Git and pick your Eleventy repository. Set two values:

  • Build command: npm run build
  • Output directory: _site (Eleventy's default)

Pages detects package.json and runs npm ci before your build. npm ci installs strictly from package-lock.json and fails fast if the lockfile is out of sync with package.json, which is exactly the behaviour you want on a build host — a reproducible install rather than a resolver that quietly drifts. Eleventy 3 requires Node 18 or newer, so pin the runtime; the simplest way is a .nvmrc file at the repo root, which Pages reads automatically:

22

Pin it explicitly rather than trusting the platform default: Pages upgrades its default Node line over time, and an unpinned build that works today can break on a future runtime bump without a single line of your code changing. If your Eleventy site lives in a subdirectory of a monorepo, set Root directory in the build settings so package.json, _site, and .nvmrc all resolve relative to that folder.

Keep the build production-flagged and quiet so the logs stay short and the output stays optimized:

{
  "scripts": {
    "build": "ELEVENTY_ENV=production npx @11ty/eleventy --quiet"
  }
}

--quiet suppresses the per-file render log, which keeps the Pages build output readable and shaves a little I/O off large sites. Setting ELEVENTY_ENV=production gives your templates a reliable flag to branch on — for example, injecting analytics or minifying HTML only when it is not a local dev run.

Environment Variables

Add variables under Settings → Environment variables, scoped to production or preview as needed. Pages injects them into the Node build process, so read them in eleventy.config.js or your data files via process.env.YOUR_VAR — Eleventy does not pass them to templates automatically. To expose a value to templates, surface it through global data:

// eleventy.config.js
module.exports = function (eleventyConfig) {
  eleventyConfig.addGlobalData("siteEnv", () => process.env.ELEVENTY_ENV || "development");
};

Scoping matters: a variable set for Preview only will be undefined in the production build, so a data file that assumes it exists throws and fails the deploy. Give every environment-derived value a fallback like the || "development" above. Never commit a real .env file — keep it in .gitignore and define the values in the dashboard; secrets belong in Pages' encrypted variables, not in the repo.

Environment variables also let you steer build behaviour without changing code. If your builds are slow enough to matter, pair this deploy with enabling incremental builds in Eleventy so warm rebuilds only re-render changed templates rather than the whole tree.

Cache Headers

Ship a _headers file in _site/. Author it in your input directory and let Eleventy passthrough-copy it into the output:

// eleventy.config.js — copy _headers verbatim into _site/
eleventyConfig.addPassthroughCopy("_headers");

Long-cache hashed assets, revalidate HTML:

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

The immutable directive on fingerprinted asset paths tells browsers never to revalidate within the year-long window, so a returning visitor pays zero conditional requests for CSS, JS, and images. HTML uses max-age=0, must-revalidate so every navigation checks freshness — cheap, because the edge answers the revalidation, and it means a new deploy is visible immediately rather than after a stale TTL expires. A sibling _redirects file (also passthrough-copied) covers vanity URLs and old-path redirects the same way.

For the full s-maxage, stale-while-revalidate, and purge-automation reference, see the parent Cloudflare Pages Edge Caching Setup; the header syntax itself is covered in depth in setting cache-control headers on Cloudflare Pages. A fresh Pages deploy invalidates changed files automatically, so you usually do not purge manually.

Preview Deployments & Branch Builds

Once the repo is connected, Pages builds every branch, not just main. A push to main updates the production alias; a push to any other branch — or an open pull request — produces an isolated preview URL of the form https://<hash>.<project>.pages.dev that runs the same build with the Preview environment variables. Reviewers see the real, rendered Eleventy output on the edge before anything merges, and each preview is immutable, so a link you paste into a PR keeps showing that exact build.

Branch and pull-request preview isolation on Cloudflare Pages One connected repository feeds three build lanes: a push to main updates the stable production alias, while a feature branch and an open pull request each produce their own immutable preview URL on the project's pages.dev subdomain. One repo, one production alias, an isolated URL per branch Connected repo one build config same npm ci + build push to main production branch push feature/x any other branch open pull request per-PR preview Production alias your-domain.com — stable, always latest Immutable preview a1b2c3d4.project.pages.dev Immutable preview e5f6a7b8.project.pages.dev Every non-production build gets its own preview URL, frozen to that exact commit.
`main` re-points the stable production alias; every other branch and pull request builds to its own `*.pages.dev` preview URL that stays frozen to that commit.

Two settings make previews reliable:

  • Build watch paths (Settings → Builds) let you skip a deploy when a push only touches files that cannot affect the output — docs, CI config — so you do not burn a build minute on a README typo.
  • Branch control lets you restrict which branches produce previews if an active repo would otherwise spawn dozens of pages.dev URLs.

If you want previews to post their URL back onto the pull request and gate merges on a green build, drive them from CI as described in automating preview deploy pipelines with GitHub Actions.

Adding Pages Functions to a Static Eleventy Site

A pure Eleventy site is fully static, but you can add dynamic routes without leaving Pages. Drop a functions/ directory at the repo root and Pages deploys those handlers alongside your _site output; each function only owns its matched route, so the rest of the site stays static and edge-cached.

// functions/api/subscribe.js — runs at the edge, not at build time
export async function onRequestPost({ request, env }) {
  const { email } = await request.json();
  await env.NEWSLETTER.put(email, Date.now().toString());
  return new Response(JSON.stringify({ ok: true }), {
    headers: { "content-type": "application/json" },
  });
}

This keeps the deploy model unchanged — one git push, one atomic release — while giving you form handlers, redirects with logic, or API proxies that a build-time-only generator cannot express. The Hugo counterpart, which leans harder on Workers for edge logic, is covered in Deploying Hugo to Cloudflare Pages and Workers.

Measured Impact

On a 180-page Eleventy documentation site deployed from a connected GitHub repo, the Pages build and deploy timeline looked like this. The cold build pays the full npm ci cost; warm builds reuse the dependency cache Pages keeps between deploys:

StageCold buildWarm build
npm ci (install)38 s9 s
eleventy --quiet (render 180 pages)6 s6 s
Upload + atomic edge propagation11 s11 s
Total push-to-live~55 s~26 s

Render time is flat because Eleventy rebuilds the whole site each run; the variable cost is the install step. After the first edge MISS, repeat visits to an HTML route served cf-cache-status: HIT with TTFB around 30 ms from a nearby point of presence, versus roughly 180 ms when the request reached origin.

Pitfalls & Rollback

  • No Node version pinned: Pages may default to an older runtime and Eleventy 3 fails. Add .nvmrc (22) or set the version in the dashboard.
  • Env vars missing in templates: they live only in the Node build. Read them via process.env and expose them with addGlobalData.
  • Module not found on build: a stale lockfile. Run npm install locally, commit package-lock.json, and confirm the package is in dependencies/devDependencies.
  • _headers not copied: without addPassthroughCopy, the file never reaches _site/ and all your cache rules silently vanish. Verify with curl -I after deploy.
  • Rollback: open the Pages project's Deployments tab and click Rollback to this deployment on a previous build — it re-points the live alias to that immutable artifact in seconds. Because HTML uses must-revalidate, browsers pick up the rolled-back version on their next request rather than serving the bad release.
The four settings that decide whether it works Four panels of Cloudflare Pages settings for an Eleventy site: build command, output directory, Node version and environment variables. Each names the symptom when it is wrong. The four settings that decide whether it works Build + output npx @11ty/eleventy _site as the output directory wrong output → empty deployment wrong command → build error Node version NODE_VERSION pinned explicitly matches your local version unpinned → surprise major upgrade symptom: works locally, fails in CI Env vars scoped per environment preview vs production values unscoped → staging indexed symptom: analytics on a preview Set all four before the first deploy; retrofitting them means re-testing every page.
Three settings, three distinct failure modes — and all three are silent until someone looks at the deployed site.

Conclusion

Automating Eleventy on Cloudflare Pages comes down to three build settings — build command, _site output, and a pinned Node runtime — plus a passthrough-copied _headers file. After that, git push is your entire deploy pipeline: production on main, an isolated preview URL on every other branch and pull request, optional Pages Functions for the dynamic edges, and a one-click rollback when a release goes wrong. The full edge-cache tuning lives in Cloudflare Pages Edge Caching Setup, and the Hugo equivalent — including pushing more logic to the edge — is in Deploying Hugo to Cloudflare Pages and Workers.

FAQ

Why does my Eleventy build fail with Module not found on Cloudflare Pages?

The lockfile is stale or a build dependency is missing. Run npm install locally, commit the updated package-lock.json, and confirm the package is listed in dependencies or devDependencies so npm ci installs it during the Pages build.

How do I purge the cache after an Eleventy deploy?

Usually you do not need to, because a new Pages deploy invalidates the files that changed. For an external zone fronting Pages, POST to the Cloudflare purge API on deploy success with a files array of the changed URLs.

Can I use Pages Functions with an Eleventy site?

Yes. Put functions in a functions/ directory at the repo root and Pages deploys them alongside your _site output. Each function only handles its matched route, so the rest of the site stays fully static.

How do I expose a build-time environment variable to Eleventy templates?

Pages injects variables into the Node build process only, so read them via process.env in your config and surface them through addGlobalData. Eleventy does not pass environment variables to templates automatically.