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.
curlavailable locally to inspect response headers against the deployed URL.
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.
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.devURLs.
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:
| Stage | Cold build | Warm build |
|---|---|---|
npm ci (install) | 38 s | 9 s |
eleventy --quiet (render 180 pages) | 6 s | 6 s |
| Upload + atomic edge propagation | 11 s | 11 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.envand expose them withaddGlobalData. Module not foundon build: a stale lockfile. Runnpm installlocally, commitpackage-lock.json, and confirm the package is independencies/devDependencies._headersnot copied: withoutaddPassthroughCopy, the file never reaches_site/and all your cache rules silently vanish. Verify withcurl -Iafter 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.
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.
Related
- Parent: Cloudflare Pages Edge Caching Setup — the full
_headers,s-maxage, and purge reference this recipe builds on. - Deploying Hugo to Cloudflare Pages and Workers — the Hugo equivalent, with more edge logic pushed into Workers.
- Enabling Incremental Builds in Eleventy — cut the render step so warm builds only touch changed templates.
- Setting Cache-Control Headers on Cloudflare Pages — the header syntax behind the
_headersrules above. - Automating Preview Deploy Pipelines with GitHub Actions — post preview URLs back to PRs and gate merges on a green build.
- How to Set Up GitHub Actions for Hugo Deployments — building in CI instead of on the host.