Skip to content
Hogin Hogin
Go back

Deploy Astro to Cloudflare Pages: Git Integration and GitHub CI/CD

11 мин чтения

Cloudflare Pages serves static files from a CDN in 300+ cities for free, and Astro builds static files by default. You can wire them together two ways: connect the repo right in the dashboard — and Cloudflare builds and publishes on every push to main, with zero YAML; or build it yourself in GitHub Actions and ship the finished dist through Wrangler when you need control over the pipeline. Let’s walk both, starting with the no-CI path: a five-minute setup, a custom domain, and a separate dev environment.

Table of contents

Open Table of contents

Two ways to deploy to Pages

Cloudflare Pages has two modes, and they’re easy to confuse.

Git integration. You connect the repo in the dashboard, Cloudflare clones the code, runs the build on its own builders and publishes the result. Zero YAML, an automatic preview on every branch and pull request, and a push to main goes straight to prod. It’s the fastest path, and for most projects — a blog, a landing page, docs — it’s more than enough.

Direct Upload from your CI. You build the project yourself — in GitHub Actions — and push the finished dist folder to Pages with the Wrangler CLI. You write the YAML and hold the secrets, but you fully own the pipeline: any Node version, pnpm cache, a gate on tests and linters, shared steps with other jobs.

Simple rule: start with Git integration, move to Actions when you hit its ceiling — you need tests gating publication, a non-default Node version, or a pipeline shared with other services.

First, pick the Astro mode

It decides both the build command and whether you need an adapter.

Static — output: 'static' (the default). Astro renders every page to HTML at build time and drops it into dist. No adapter needed; Pages just serves the files. This is the blog, landing page and docs case — the fastest and cheapest option, and what most projects want.

SSR — output: 'server' with the @astrojs/cloudflare adapter. Needed when some pages render on the fly: a dashboard, API routes, cookie-based personalization. The build adds a _worker.js to dist — a Pages Function that runs at the edge. One command wires it up:

pnpm astro add cloudflare

It installs the adapter and configures it in astro.config.mjs. Then mark dynamic pages with export const prerender = false; everything else Astro still prerenders to static.

Astro static versus server with the Cloudflare adapter

Either way the deploy artifact is the dist folder. From here on it’s identical.

The no-CI path: Git integration in five minutes

The shortest route is to write no YAML at all and hand the build to Cloudflare.

In the dashboard: Workers & Pages → Create → Pages → Import an existing Git repository. Connect GitHub (grant access to the repo, or the whole account, once), pick the repository and the production branch main. Then the build settings:

FieldValue
Framework presetAstro
Build commandpnpm build
Build output directorydist
Root directory/ (empty)

Cloudflare detects pnpm from the packageManager field in package.json — no need to install it separately. Hit Save and Deploy: Cloudflare clones the repo, runs pnpm build on its builders, and a minute later serves the site at <project>.pages.dev.

From here on every push to main builds and publishes prod automatically — no tags, no manual step, no secrets in the repo. Push to another branch or open a pull request, and Cloudflare builds it into a separate preview (more on that below).

One thing that’s easy to trip on: if package.json pins packageManager: [email protected], pnpm needs Node ≥ 22.13 — and the default Node on Cloudflare’s builders is older, so the build fails with This version of pnpm requires at least Node.js v22.13. The fix is a single environment variable: under Settings → Variables and Secrets add NODE_VERSION set to 22.22.2 (any version ≥ 22.13) and rebuild.

Custom domain

After the first deploy the site lives at <project>.pages.dev. To put it on your own domain: in the project, Custom domains → Set up a domain, enter example.com. If the domain is already served by Cloudflare, the dashboard creates the needed DNS record itself (a CNAME to <project>.pages.dev, CNAME-flattened for the apex); if it’s at a third-party registrar, Cloudflare shows you which CNAME to add by hand.

The TLS certificate is issued and renewed automatically — nothing to configure. Add both the apex (example.com) and www if you want both, and pick the primary one — the others redirect to it. Every subsequent deploy publishes straight to your domain; you never touch DNS again.

A separate dev environment: preview deploys

Git integration gives you a second environment for free. Prod is only the production branch (main); any other branch and any pull request build into a separate preview deploy with its own address <hash>.<project>.pages.dev, plus a stable <branch>.<project>.pages.dev for a named branch. Prod stays untouched — handy for drafts and reviews.

You can split the environments by config too. Under Settings → Variables and Secrets, variables are set separately for Production and Preview: for example, plug a test analytics ID into preview (or drop it entirely) and add a noindex so drafts don’t end up in search. Same trick for different API keys or feature flags.

The workflow becomes: push the draft/new-post branch → Cloudflare builds it into a preview, you check it live on a separate URL → merge into main → prod updates automatically.

When to move to GitHub Actions

Git integration hits its ceiling where you need control over the build: run tests as a gate before publishing, pin an exact Node version, reuse cache and steps across other jobs. Then you pull the build into your own CI and ship a finished dist to Pages.

Git integrationGitHub Actions
Where the build runsCloudflare buildersyour CI
Setupa few dashboard clicksa YAML file in the repo
Node version, cachinglimitedfull control
Tests before deployseparatesame pipeline
Preview per branchautomaticvia the --branch flag
Secretsin the dashboardin GitHub Secrets

Pipeline: a push to main triggers a build in GitHub Actions and a deploy via Wrangler

What you need to deploy from Actions

Four things: a Pages project, an API token, two repo secrets, and a workflow file.

1. Create the Pages project. A CI deploy must never prompt interactively, so create the project ahead of time — in the dashboard (Workers & Pages → Create → Pages → Direct Upload) or with one local command:

npx wrangler pages project create my-astro-app \
  --production-branch main

The name my-astro-app becomes the my-astro-app.pages.dev subdomain and appears in the deploy command.

2. Issue an API token. In My Profile → API Tokens → Create Token use the Edit Cloudflare Pages template (or a custom token with Account → Cloudflare Pages → Edit). While in the dashboard, copy your Account ID — it’s on the account home page on the right.

3. Put the secrets in the repo. Under Settings → Secrets and variables → Actions add two:

Never inline them in the YAML — a token with Pages write access has no business in git history.

4. Add the workflow. Create .github/workflows/deploy.yml:

name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]

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

      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist --project-name=my-astro-app

That’s it. pnpm build calls astro build and writes the site to dist, then wrangler-action takes that folder and publishes it. pnpm/action-setup with no version reads it from the packageManager field in package.json. The pages deploy command is the same for static and SSR — Wrangler picks up _worker.js if it’s there. On npm, swap pnpm for npm ci and npm run build and drop the pnpm/action-setup step.

Preview branches in Actions

In Git integration, preview deploys are on by default. In Actions you wire them up yourself: out of the box the workflow above publishes only to production. Wrangler splits branches with the --branch flag — a deploy to the project’s production branch (main) goes to prod, any other branch goes to a preview at <branch>.my-astro-app.pages.dev.

Pushes to main go to production, pushes to a branch go to preview

To enable it, widen the trigger to all branches and pass the current branch name into the command:

on:
  push:
    branches: ["**"]
# ...
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: >-
            pages deploy dist
            --project-name=my-astro-app
            --branch=${{ github.ref_name }}

Now every branch gets its own stable preview address — handy for review and QA before merging.

How to check it deployed

With Git integration the build log is right in the dashboard, under the project’s Deployments tab; with Actions the first sign is a green check in Actions, where the deploy step prints the URL of the published version at the end. Open it and verify the contents.

From the outside it’s one command — Cloudflare responses carry the telltale server: cloudflare and cf-ray headers:

curl -sI https://my-astro-app.pages.dev | grep -iE 'http/|server|cf-ray'
# HTTP/2 200
# server: cloudflare
# cf-ray: 8a1f...-DME

For an SSR page, confirm the dynamic part runs at the edge rather than serving cached HTML — for example, print the current time on the page and refresh a couple of times. The full deploy history, tied to commit and branch, lives in the project’s Deployments tab in the dashboard, where you can also roll back to any previous version in one click.

Common gotchas

Four mistakes that eat the most time.

Node version for pnpm. This one is about Git integration: if package.json pins packageManager: [email protected], pnpm requires Node ≥ 22.13. The default Node on Cloudflare’s builders is lower, and the build fails with This version of pnpm requires at least Node.js v22.13. Set a NODE_VERSION variable (22.22.2, or anything ≥ 22.13) under Settings → Variables and Secrets and rebuild. In GitHub Actions the same thing is fixed with node-version: 22 in setup-node.

nodejs_compat for SSR. If an SSR page uses a Node API (say node:crypto or Buffer), the deploy succeeds but the page crashes at runtime. The @astrojs/cloudflare adapter needs a compatibility flag. Add a wrangler.toml at the root:

compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

Wrangler reads it during pages deploy.

Project name. The --project-name value must match the Cloudflare project name exactly. A typo, and in non-interactive CI Wrangler can’t ask which project to publish to — the step fails. It’s the first thing to check on a failed deploy.

Build folder. Astro always builds into dist unless outDir is overridden in the config. The pages deploy dist command assumes the default; if you changed the path, fix it here too.

Bottom line

Cloudflare Pages has two paths, and you don’t have to pick one for good. Git integration is five minutes in the dashboard: connect the repo, and every push to main builds and publishes itself, with free per-branch previews and automatic TLS on your domain. The moment you need control — tests before publishing, a non-default Node version, a pipeline shared with other services — you move to Direct Upload from GitHub Actions: a dozen lines of YAML and two secrets. A static Astro site needs only the default output: 'static'; a dynamic one adds the @astrojs/cloudflare adapter — and the deploy command stays the same either way.


Share this post:

Previous Post
Self-hosted Matrix + Element: a messenger that's actually yours
Next Post
HTTP/3 and QUIC Explained Simply