Table of Contents

I wanted to build a technical blog or a small demo platform, but without wrestling with a complicated backend environment every time I deploy. This post records how I made everything lightweight using Astro + Cloudflare Workers, including the pitfalls I hit and the details that actually matter.

Why This Stack

There are plenty of static-site options out there: Vercel, Netlify, Railway, each with its own strengths. But if your requirements are “lightweight frontend + a bit of dynamic API + global edge deployment + near-zero ops cost,” Cloudflare’s level of integration is the highest.

Cloudflare Pages + WorkersVercelNetlify
Edge executionWorkers (V8 isolates)Edge Functions (Node)Edge Functions (Deno)
DatabaseD1 (SQLite), KVExternal requiredExternal required
Vector databaseVectorize (built-in)External requiredExternal required
Free tier100k Workers requests/dayLimitedLimited
Cold startVirtually none (isolate)YesYes

The price of choosing Cloudflare: its runtime is Workers (V8 isolates), not a full Node.js. A handful of npm packages are incompatible, and fs, path, and child_process simply don’t exist. That’s something to confirm before you commit.

Overall Architecture

graph TB
  Browser["瀏覽器"]
  CF["Cloudflare Pages\n靜態 CDN"]
  Worker["Cloudflare Workers\nSSR + API"]
  D1["D1(SQLite)"]
  KV["KV Store"]
  R2["R2(物件儲存)"]
  AI["Workers AI"]

  Browser -- "靜態資源" --> CF
  Browser -- "API / SSR 請求" --> Worker
  Worker --> D1
  Worker --> KV
  Worker --> R2
  Worker --> AI
  CF -- "Pages Functions" --> Worker

Once Astro uses the Cloudflare adapter, both SSR pages and API routes run on Workers, while static assets (JS, CSS, images) are cached and distributed by the Pages CDN. The two roles have a clear division of labor and don’t interfere with each other.

Starting from Scratch

1. Initialize the Astro Project

pnpm create astro@latest my-site
cd my-site
pnpm add @astrojs/cloudflare

Modify astro.config.mjs:

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',
  adapter: cloudflare({
    mode: 'directory',
    platformProxy: {
      enabled: true,
    },
  }),
});

mode: 'directory' makes the output structure match the directory format of Cloudflare Pages Functions. platformProxy: { enabled: true } is the key to emulating the Cloudflare environment during local development — without it, locals.runtime will be undefined locally, which makes debugging extremely painful.

If you need multilingual routing (Traditional Chinese as default, English under /en/*):

export default defineConfig({
  output: 'server',
  adapter: cloudflare({ mode: 'directory', platformProxy: { enabled: true } }),
  i18n: {
    defaultLocale: 'zh-TW',
    locales: ['zh-TW', 'en'],
    routing: { prefixDefaultLocale: false },
  },
});

2. Configure wrangler.jsonc

The bindings for all Cloudflare services are managed centrally in wrangler.jsonc. The binding is the name you reference in code, e.g. env.DB, env.AI.

{
  "name": "my-site",
  "compatibility_date": "2025-09-01",
  "pages_build_output_dir": "./dist",

  "d1_databases": [{
    "binding": "DB",
    "database_name": "my-site-db",
    "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  }],

  "kv_namespaces": [{
    "binding": "CACHE",
    "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  }],

  "r2_buckets": [{
    "binding": "STORAGE",
    "bucket_name": "my-site-assets"
  }],

  "ai": {
    "binding": "AI"
  }
}

If you want to use some Node.js built-ins (crypto, buffer, stream), add:

{
  "compatibility_flags": ["nodejs_compat"]
}

Note: nodejs_compat does not include fs, path, or child_process.

3. Use Bindings in an API Route

I recommend first declaring an Env interface in src/env.d.ts so that locals.runtime.env is typed:

// src/env.d.ts
type Runtime = import('@astrojs/cloudflare').Runtime<Env>;

interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
  STORAGE: R2Bucket;
  AI: Ai;
  ADMIN_TOKEN: string;
}

declare namespace App {
  interface Locals extends Runtime {}
}

After this, the IDE autocomplete will correctly suggest D1’s methods. An example API route (src/pages/api/count.ts):

import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ locals }) => {
  const { DB } = locals.runtime.env;

  const result = await DB
    .prepare('SELECT count(*) as cnt FROM posts')
    .first<{ cnt: number }>();

  return Response.json({ count: result?.cnt ?? 0 });
};

Workers AI is accessed the same way:

const { AI } = locals.runtime.env;
const embedding = await AI.run('@cf/baai/bge-m3', {
  text: ['搜尋關鍵字'],
});

4. Create the D1 Database

D1 is Cloudflare’s SQLite-compatible edge database. Inside Workers it’s a local call with virtually no connection latency.

# Create D1
wrangler d1 create my-site-db

# Run migrations locally
wrangler d1 execute my-site-db --local --file=./migrations/0001_init.sql

# Run migrations remotely
wrangler d1 execute my-site-db --remote --file=./migrations/0001_init.sql

Put migration files in the migrations/ directory, managed with version-number prefixes:

-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS posts (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  date TEXT NOT NULL,
  category TEXT NOT NULL,
  lang TEXT NOT NULL DEFAULT 'zh-TW',
  tags TEXT,
  description TEXT
);

CREATE INDEX IF NOT EXISTS idx_posts_date ON posts(date DESC);
CREATE INDEX IF NOT EXISTS idx_posts_category ON posts(category);

Naming convention: NNNN_description.sql, where the numeric prefix guarantees execution order.

5. Deploy with GitHub Actions

.github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - run: pnpm install
      - 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-site

Add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID under the GitHub repo’s Settings → Secrets and variables → Actions. Create the token in Cloudflare Dashboard → My Profile → API Tokens → Create Token, choose “Custom token,” and it needs at minimum Cloudflare Pages: Edit permission; if your workflow runs migrations, it also needs D1: Edit.

Pages generates a separate Preview URL for every branch, making it easy to verify the result before merging into main, with no manual intervention required.

A Few Common Pitfalls

Environment Variables vs. Bindings Are Two Separate Systems

Symptom: locally pnpm dev can read a secret, but after deployment env.MY_SECRET is undefined.

Cause: wrangler.jsonc bindings (DB, KV, R2) and the Cloudflare Dashboard’s Environment Variables are two completely independent systems. Local dev uses .dev.vars to emulate env vars, but the two are accessed differently.

Fix: put secrets (API tokens, passwords) in the Dashboard’s Environment Variables and access them via env.MY_SECRET; put database, KV, and R2 in wrangler.jsonc bindings and access them via env.DB. Use .dev.vars (added to .gitignore) for local development secrets.

Node.js APIs Are Incompatible

Symptom: after importing some npm package, the deploy reports Cannot find module 'fs'.

Cause: the Workers runtime is V8 isolates, not Node.js, so fs, path, and child_process simply don’t exist.

Fix: check whether the npm package has a Workers-compatible version; or enable the nodejs_compat compatibility flag (which supports crypto, buffer, etc., but not fs).

Data Contamination in the D1 Preview Environment

Symptom: after running tests in a PR’s Preview environment, production data is mysteriously modified.

Cause: by default, every branch’s Pages Preview environment points to the same database_id in wrangler.jsonc — namely the production D1. Writes in the Preview environment directly affect production data.

Fix: in the Cloudflare Pages project settings → Environment Variables, override database_id for the Preview environment to point to a dedicated staging D1 database.

The Statelessness of Isolates

Symptom: a module-level variable gets set after the first request, but reverts to its initial value on the second request.

Cause: each request in Workers V8 isolates is an independent execution environment, so global variables don’t persist between requests. This differs from a traditional Node.js server.

Fix: put any state that needs to be shared across requests (sessions, caches) in KV or D1 — don’t rely on module-level variables.

What I Learned

  • Cloudflare’s integration is its biggest selling point: D1, KV, R2, Vectorize, and Workers AI are all on the same platform, so you don’t have to manage credentials and IAM across multiple services.
  • Don’t forget to enable platformProxy: { enabled: true }. Without it, locals.runtime is undefined during local development.
  • Astro content collections’ Zod schema lets you catch frontmatter errors locally, instead of waiting for CI.
  • Keep sensitive keys in the Cloudflare Dashboard / GitHub Secrets; in wrangler.jsonc, put only binding names and resource IDs, never secrets.

References

Ask this article

Answers come from this article only. Click any prompt below or open the chat at the bottom right.

🇺🇸 English

So you want to build a technical blog, or maybe a little demo platform — but you don't want to wrestle with some complicated backend setup every single time you deploy. That's exactly the problem I set out to solve, and the answer I landed on was Astro plus Cloudflare Workers. Let me walk you through how I made the whole thing lightweight, including the traps I fell into and the details that actually matter.

First, why this particular stack? There are plenty of static-site hosts out there — Vercel, Netlify, Railway, they've all got their strengths. But if your wish list reads something like "a light frontend, a sprinkle of dynamic API, global edge deployment, and almost zero operations cost," then Cloudflare wins on one thing above all: integration.

Here's the comparison in plain terms. For edge execution, Cloudflare runs your code on Workers using V8 isolates, while Vercel uses Node-based edge functions and Netlify uses Deno-based ones. The big difference shows up with data. Cloudflare gives you a database built in — that's D1, which is SQLite — plus a key-value store, and even a vector database called Vectorize, right there on the platform. With Vercel or Netlify, you're reaching for an external service for all of those. On the free tier, Cloudflare hands you a hundred thousand Worker requests a day. And cold starts? Because isolates spin up almost instantly, cold start is virtually a non-issue, whereas the others do make you wait.

Now, there's a price for all this. The Cloudflare runtime is Workers — V8 isolates — not a full Node.js environment. So a handful of npm packages just won't work, and the Node built-ins like file system, path, and child process simply don't exist. That's worth confirming before you commit your whole project to it.

Let me paint the architecture. Picture the browser making two kinds of requests. Static assets — your JavaScript, CSS, images — get served straight from the Pages CDN, cached and distributed globally. But anything dynamic, an API call or a server-rendered page, goes to Workers instead. And it's the Worker that talks to everything else: the D1 database, the key-value store, R2 object storage, and Workers AI. Once Astro is wired up with the Cloudflare adapter, your server-rendered pages and your API routes all run on Workers, while the static stuff lives on the CDN. Clean division of labor, and the two never step on each other's toes.

Okay, let's build it from scratch.

Step one, spin up the Astro project and add the Cloudflare adapter. In your Astro config, you set output to "server" and plug in the Cloudflare adapter. Two settings really matter here. You set mode to "directory," which makes the output match the folder structure Cloudflare Pages Functions expects. And then — this is the one people forget — you enable platformProxy. That flag is what emulates the Cloudflare environment when you're developing locally. Skip it, and your runtime object comes back undefined on your own machine, which turns debugging into absolute misery. If you want multilingual routing, say Traditional Chinese as the default with English living under a slash-en path, Astro's i18n config handles that too — you just declare your locales and tell it not to prefix the default language.

Step two, configure wrangler.jsonc. This is the single place where you manage every Cloudflare service binding. A binding has two parts: a name you reference in code — like env.DB or env.AI — and the actual resource it points to. So in that file you'd declare your D1 database with the binding "DB," your KV namespace as "CACHE," your R2 bucket as "STORAGE," and Workers AI simply as "AI." If you need a few Node built-ins like crypto, buffer, or stream, you add the nodejs_compat compatibility flag. But heads up — that flag does not bring back file system, path, or child process. Those are gone for good.

Step three, actually use those bindings in an API route. My strong recommendation: declare an Env interface in your env.d.ts file first, listing each binding and its type — the database, the KV store, R2, the AI binding, your admin token, and so on. Do that, and your editor's autocomplete starts correctly suggesting D1's methods. Then an API route is genuinely simple. You pull the database binding off of locals.runtime.env, run a prepared statement — say, counting the rows in your posts table — and return it as JSON. Workers AI works the exact same way: grab the AI binding, call run with a model name like bge-m3, hand it your text, and you get an embedding back. Same pattern every time.

Step four, create the D1 database. D1 is Cloudflare's SQLite-compatible edge database, and because it lives right next to your Worker, calling it feels local — there's basically no connection latency. You create it with one wrangler command, then run your migrations, and here's the key distinction: there's a local flag and a remote flag. Local applies the migration to your dev copy, remote applies it to the real thing. Keep your migration files in a migrations folder and prefix them with version numbers — zero-zero-zero-one, zero-zero-zero-two, and so on — because that numeric prefix is what guarantees they run in the right order.

Step five, deploy through GitHub Actions. The workflow is short: check out the code, set up pnpm, install, build, and then hand off to Cloudflare's wrangler action to deploy your dist folder to Pages. It authenticates with two secrets — your Cloudflare API token and your account ID — which you store in the GitHub repo under Settings, Secrets and variables, Actions. To create that token, you go into the Cloudflare dashboard, into API Tokens, pick "Custom token," and give it at minimum Cloudflare Pages Edit permission. If your workflow also runs migrations, it needs D1 Edit too. And here's a nice bonus: Pages spins up a separate preview URL for every branch, so you can eyeball your changes before merging to main, no manual steps required.

Now, the pitfalls — and these are the ones that cost me real time.

Pitfall number one: environment variables and bindings are two completely separate systems. The symptom is maddening. Locally, your dev server reads a secret just fine, but after you deploy, that same secret comes back undefined. Here's what's going on: the bindings in wrangler.jsonc — your database, KV, R2 — and the environment variables in the Cloudflare dashboard are two independent worlds. Locally, you fake environment variables using a dotfile called .dev.vars, but they're accessed through a different mechanism than bindings. So the rule is: put your real secrets — API tokens, passwords — in the dashboard's environment variables. Put your database, KV, and R2 in wrangler.jsonc as bindings. And for local development secrets, use that .dev.vars file — and absolutely add it to your gitignore.

Pitfall number two: the Node.js APIs that aren't there. You import some npm package, deploy, and the build screams that it can't find module "fs." Right — because the runtime is V8 isolates, not Node, so file system, path, and child process don't exist. Your move is to check whether the package has a Workers-compatible build, or enable the nodejs_compat flag for things like crypto and buffer. Just remember it still won't give you file system access.

Pitfall number three, and this one's a little scary: data contamination in your D1 preview environment. The symptom — you run some tests in a pull request's preview environment, and somehow your production data gets modified. Here's the trap. By default, every branch's preview environment points at the same database ID in wrangler.jsonc, which is your production database. So any write you do in preview is hitting production directly. The fix is to go into your Cloudflare Pages project settings, find the environment variables, and override the database ID specifically for the preview environment so it points at a dedicated staging database. Do that before you ever run a test against preview.

Pitfall number four: isolates are stateless. You set some module-level variable on the first request, and on the second request it's back to its starting value. That's because in Workers, each request is its own isolated execution environment — global variables just don't persist between requests, which is a real departure from a traditional Node server that stays running. So anything you need to share across requests — sessions, caches — goes into KV or D1. Never lean on a module-level variable to hold state.

So let me leave you with the three things that really matter here.

First, integration is the whole point of Cloudflare. D1, KV, R2, Vectorize, and Workers AI all live on one platform, which means you're not juggling credentials and access policies across a half-dozen separate services. That's the friction you're buying your way out of.

Second, don't forget to enable platformProxy. I know I've said it twice now, but it's the single easiest thing to miss and it'll have you staring at an undefined runtime wondering what broke.

And third, be disciplined about secrets. Keep your sensitive keys in the Cloudflare dashboard and in GitHub Secrets. In wrangler.jsonc, you only ever put binding names and resource IDs — never an actual secret. Get those three right, and you've got a platform that's genuinely low-friction, the way it was supposed to be.

🇹🇼 中文

想建一個技術部落格,或一個小型的 demo 平台,但又不想每次部署都跟複雜的後端環境搏鬥。今天就聊聊我怎麼用 Astro 加上 Cloudflare Workers,把整套東西變得超級輕量。中間踩到的坑,還有那些真正重要的細節,我都會講。

先說,為什麼是這個組合。靜態網站的選擇其實不少,Vercel、Netlify、Railway,各有各的強項。但如果你的需求很明確,就是「輕量前端、加上一點點動態 API、要全球邊緣部署、而且維運成本幾乎是零」,那 Cloudflare 的整合度是目前最高的。

它高在哪?邊緣執行,它用的是 Workers,也就是 V8 isolates;資料庫有內建的 D1,是 SQLite,還有 KV;甚至連向量資料庫 Vectorize 都是內建的。相對之下,Vercel 跟 Netlify 的資料庫跟向量庫都得另外外接。免費方案上,Workers 每天給你十萬次請求。最關鍵的是冷啟動,因為是 isolate,幾乎沒有 cold start,這點 Vercel 跟 Netlify 都還是會有。

但天下沒有白吃的午餐。選 Cloudflare 的代價是,它的 runtime 是 Workers,不是完整的 Node.js。少數 npm 套件會不相容,而且 fs、path、child_process 這些 Node 內建模組,完全不存在。這是你進場之前,一定要先確認的事。

架構上其實很乾淨。瀏覽器要靜態資源,JS、CSS、圖片這些,就走 Cloudflare Pages 的 CDN 快取分發;要 API 或 SSR 渲染的請求,就交給 Workers 處理。而 Workers 後面再接 D1、KV、R2,還有 Workers AI。兩個角色,一個管靜態、一個管動態,分工很清楚,互不干擾。

接下來講怎麼從零開始,我挑幾個關鍵的點。

初始化專案之後,重點在改 astro.config。adapter 設成 Cloudflare,mode 給它 directory,讓輸出對應 Pages Functions 的目錄格式。然後有一個東西絕對不能忘,就是 platformProxy,要把 enabled 設成 true。這是本地開發時模擬 Cloudflare 環境的關鍵。沒有它,你在本地拿 locals.runtime 會是 undefined,debug 會痛苦到懷疑人生。如果要做多語言,i18n 那邊設一下 defaultLocale 跟 locales 就好。

再來是 wrangler.jsonc。所有 Cloudflare 服務的 binding,都在這個檔案集中管理。binding 就是你程式碼裡取用的名字,比如 env.DB、env.AI。D1、KV、R2、AI,每個服務在這裡宣告一次,給它一個 binding 名稱跟 resource ID。如果你想用部分 Node 內建,像 crypto、buffer、stream,要加上 nodejs_compat 這個 compatibility flag。但再強調一次,nodejs_compat 不包含 fs、path、child_process。

在 API route 裡面用 bindings,建議先在 env.d.ts 宣告一個 Env interface,把 DB、CACHE、AI 這些型別寫清楚。這樣 IDE 的 autocomplete 才會正確提示。實際取用就是從 locals.runtime.env 解構出你要的 binding,然後 DB.prepare 下 SQL,Workers AI 就 AI.run 帶模型名稱,寫法都很一致。

D1 資料庫的建立跟遷移,用 wrangler 指令。注意它分本地跟遠端,加 --local 跑本地、加 --remote 跑遠端,migration 檔放在 migrations 目錄,用數字前綴命名,比如 0001、0002,確保執行順序。

部署的話,GitHub Actions 拉一個 workflow,push 到 main 就觸發,裝相依、build、然後用 cloudflare 的 wrangler-action 部署到 Pages。Token 跟 Account ID 放在 GitHub 的 Secrets 裡。Token 在 Cloudflare Dashboard 建立,最低要 Pages Edit 權限,如果 workflow 裡還要跑 migration,那就再加 D1 Edit。

好,重頭戲,講幾個我真的踩過的坑。

第一個,也是最容易讓人崩潰的:環境變數跟 bindings,是兩套完全不同的系統。現象是,你本地 dev 讀得到 secret,一部署上去,env.MY_SECRET 就變 undefined。原因是,wrangler.jsonc 裡的 bindings,跟 Cloudflare Dashboard 上的 Environment Variables,是兩個獨立的世界。解法是:secret,像 API token、密碼這種,放 Dashboard 的環境變數;資料庫、KV、R2 這種資源,放 wrangler.jsonc 的 bindings。本地開發要用的 secret,放在 .dev.vars,記得加進 gitignore。

第二個,Node API 不相容。某個套件 import 進來,deploy 的時候報 Cannot find module fs。原因就是前面講的,Workers 不是 Node。解法是找有沒有 Workers 相容的版本,或開 nodejs_compat,但 fs 這種它救不了。

第三個坑很陰險,叫 D1 Preview 環境的資料污染。現象是,你開個 PR,Preview 環境跑了測試,結果 production 的資料莫名其妙被改了。原因是,Pages 每個 branch 的 Preview 環境,預設指向 wrangler 裡同一個 database_id,也就是 production 的 D1。所以 Preview 寫東西,等於直接動到正式資料。解法是,在 Pages 專案設定裡,針對 Preview 環境,把 database_id 覆寫成一個獨立的 staging 資料庫。

第四個,isolate 的無狀態性。你設了一個 module 層級的全域變數,第一次請求設好了,第二次請求又變回初始值。因為 V8 isolates 每次請求是獨立的執行環境,全域變數不會在請求之間留著,這跟傳統 Node server 完全不一樣。要跨請求共享的狀態,session、cache 那些,通通放 KV 或 D1,別靠全域變數。

最後幫你把這集的重點收一下。

第一,Cloudflare 最大的賣點就是整合。D1、KV、R2、Vectorize、Workers AI 全在同一個平台,你不用去管一堆服務的 credentials 跟 IAM,這是它最香的地方。

第二,記住那兩個最容易翻車的設定:platformProxy 一定要開,不然本地 runtime 是 undefined;還有環境變數跟 bindings 是兩套系統,secret 放 Dashboard,資源放 wrangler。

第三,部署上線前,務必把 Preview 環境的 D1 指到獨立的 staging 資料庫,不然你的測試會直接污染正式資料。把這幾點顧好,這套低摩擦的平台,就真的能讓你專心寫東西,而不是跟環境搏鬥。

Tags

Related Articles

What Tools This Blog Is Built With

Astro handles static rendering and content management, Cloudflare Pages/Workers handle deployment and dynamic APIs, D1 provides lightweight data storage, Vectorize + Workers AI power RAG semantic search, and R2 stores OG images and TTS audio. The entire pipeline — from YouTube crawl to user search — runs inside the Cloudflare ecosystem.