GitLab CI/CD for Cloudflare Pages: Automated Deployments with Preview Environments
Remember when the previous post ended with "Automating deployments — When I find some more time..."? Well, I found the time. Let me walk you through how I set up a GitLab CI/CD pipeline that deploys this website to Cloudflare Pages, complete with preview environments on merge requests.
The Goal
Simple:
- Push to a branch, create a merge request → preview deploy on a unique URL
- Merge to main → production deploy to ascode.nl
- No manual steps. No clicking around in dashboards.
Prerequisites
You need two things from Cloudflare:
- Account ID — find this on the sidebar of any zone in your Cloudflare dashboard
- API Token — create one at dash.cloudflare.com/profile/api-tokens with these permissions:
| Permission | Access |
|---|---|
| Account · Cloudflare Pages | Edit |
| Zone · Zone | Read |
Add both as CI/CD variables in your GitLab project (Settings → CI/CD → Variables):
CLOUDFLARE_API_TOKEN— masked, protectedCLOUDFLARE_ACCOUNT_ID— masked, protected
Wrangler (the Cloudflare CLI) picks these up automatically, no extra configuration needed.
The Pipeline
The full .gitlab-ci.yml is surprisingly short. Two stages: build the static site with Eleventy, then deploy it with Wrangler.
Build Stage
stages:
- build
- deploy
variables:
CLOUDFLARE_PROJECT_NAME: ascode-web
NODE_VERSION: "20"
build:
stage: build
image: node:${NODE_VERSION}-alpine
script:
- npm ci
- npx @11ty/eleventy
artifacts:
paths:
- _site/
expire_in: 1 hour
rules:
- if: $CI_MERGE_REQUEST_IID
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Nothing fancy. Install dependencies, run Eleventy, and pass the _site/ output as an artifact to the deploy stage. The rules ensure it runs on both merge requests and pushes to main.
Preview Deploys
This is where it gets nice:
deploy:preview:
stage: deploy
image: node:${NODE_VERSION}-alpine
needs:
- build
script:
- npx wrangler pages deploy _site/
--project-name="${CLOUDFLARE_PROJECT_NAME}"
--branch="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"
--commit-hash="${CI_COMMIT_SHORT_SHA}"
environment:
name: preview/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
url: https://${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}.${CLOUDFLARE_PROJECT_NAME}.pages.dev
rules:
- if: $CI_MERGE_REQUEST_IID
Every merge request gets its own preview URL based on the branch name. Create a branch called new-blog-post, and you get https://new-blog-post.ascode-web.pages.dev. The GitLab environment link shows up directly in the merge request, so reviewers can click through to the preview without having to figure out the URL.
Wrangler handles everything — if the Cloudflare Pages project does not exist yet, it creates it. It uploads the files, configures the deployment, and gives you the URL. No need to pre-configure anything in the Cloudflare dashboard.
Stopping Preview Environments
When a merge request is merged or closed, the preview environment should be cleaned up in GitLab:
stop:preview:
stage: deploy
image: node:${NODE_VERSION}-alpine
needs: []
script:
- echo "Preview environment stopped"
environment:
name: preview/$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
action: stop
rules:
- if: $CI_MERGE_REQUEST_IID
when: manual
allow_failure: true
Note that this only stops the GitLab environment — the Cloudflare preview deployment itself stays around until Cloudflare cleans it up (they do, eventually). If you want to actively delete it, you can add a wrangler pages deployment delete command, but I have not found the need for that.
Production Deploy
deploy:production:
stage: deploy
image: node:${NODE_VERSION}-alpine
needs:
- build
script:
- npx wrangler pages deploy _site/
--project-name="${CLOUDFLARE_PROJECT_NAME}"
--branch="main"
--commit-hash="${CI_COMMIT_SHORT_SHA}"
environment:
name: production
url: https://ascode.nl
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Merge to main → deploys to production. That's it. The --branch="main" flag tells Cloudflare this is the production deployment, which means it goes live on your custom domain.
A Note on Wrangler and npx
You might notice I am using npx wrangler instead of installing it as a project dependency. This keeps the blog repository clean — Wrangler is a deployment tool, not a site dependency. The npx command downloads it on the fly in the CI environment, which adds a few seconds to the deploy stage but keeps package.json focused on what matters: Eleventy and its plugins.
If the download time bothers you, add wrangler to your devDependencies and it will be installed during npm ci in the build stage. Either way works.
The Result
The workflow is now:
- Create a branch and write content
- Push and open a merge request
- Pipeline builds and deploys a preview — check the result
- Merge to main
- Pipeline deploys to production — live on ascode.nl
No more manual uploads to Cloudflare Pages. No more "when I find some more time." Just git push and go.
