Deploying to Cloudflare Workers and Pages with Wrangler

by goyostudio

Cloudflare runs two products that look similar from the outside and are driven by the same CLI: Workers, which executes your JavaScript or TypeScript on their edge network, and Pages, which hosts a built static site with optional Functions attached. wrangler covers both — it scaffolds projects, runs a real Workers runtime on your laptop, pushes secrets, provisions KV namespaces and R2 buckets, deploys, and streams production logs back to your terminal.

This guide walks the whole path: install and authenticate, ship a Worker, wire up configuration and storage, deploy a static site with Pages, then move all of it into CI. Everything here is wrangler v4.

## One CLI, two deploy targets

  • wrangler deploy ships a standalone Worker — a script with a fetch (or scheduled, or queue) handler, served on a workers.dev subdomain or a domain you attach.
  • wrangler pages deploy <dir> uploads a directory of built assets to a Pages project, along with anything in functions/ or a _worker.js.
  • wrangler dev and wrangler pages dev run each locally in workerd, the same runtime that serves production.
  • Storage is shared: KV, R2, D1, and Queues bind the same way for Workers and Pages Functions, and both read them off env.

Hosting a framework build with a couple of API routes? Pages is the shorter path. Writing an API, a proxy, or a cron job? Worker. wrangler deploy --assets ./dist blurs the line by letting a plain Worker serve static files too.

## Install and authenticate

# Install wrangler as a project dev dependency
npm install -D wrangler@latest
npx wrangler --version

Install per project rather than globally: the compatibility surface moves fast, and a repo-local version is what CI runs. Then authenticate once per machine.

# OAuth login, then confirm which account you got
wrangler login
wrangler whoami

login opens a Cloudflare consent page and caches a refreshable token in your OS config directory; whoami prints the email you authenticated as plus every account id that login can reach. Over SSH the browser flow can't run — use wrangler login --browser false to print the URL, or export a CLOUDFLARE_API_TOKEN instead.

// note: If you have more than one Cloudflare account — a personal one and a client's, say — run wrangler whoami before the first deploy in a project. A cached login from unrelated work will happily push your Worker into the wrong account, and the wrangler deploy output won't make that obvious. Switch with wrangler logout then wrangler login, and pin the target with account_id in your config or CLOUDFLARE_ACCOUNT_ID.

## Your first Worker

### Scaffold the project

wrangler init my-worker -y
cd my-worker

In v4, init delegates to create-cloudflare (C3), so it pulls the latest template over the network; -y accepts the defaults instead of prompting. To bring an existing dashboard-created Worker under version control, use wrangler init <name> --from-dash <worker-name> — it pulls code and settings, but not secrets.

### The config file that matters

# wrangler.toml — the minimum viable config
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]

[vars]
API_ENV = "production"

Three keys carry all the weight. name is the Worker's name on your account and its workers.dev subdomain. main is the entry point wrangler bundles with esbuild. compatibility_date pins runtime behavior — Cloudflare ships breaking changes behind dates, so an old date keeps old semantics indefinitely; set it at project creation and bump it deliberately. Add nodejs_compat only if your code imports node: built-ins.

Newer scaffolds write wrangler.jsonc instead of wrangler.toml. The keys are identical and both work; pick one per project. After any binding change run wrangler types to regenerate worker-configuration.d.ts so env.MY_KV is typed, and commit that file.

### Run it locally

wrangler dev
wrangler dev --port 8788 --var API_ENV:local
wrangler dev --remote

In v4 wrangler dev is fully local by default: your code runs in workerd and every binding is simulated against on-disk state under .wrangler/state. Nothing touches production. Reach for --remote only when you need the real thing — a live KV namespace, a real Hyperdrive connection. If local state keeps vanishing between commands, point them all at the same --persist-to path.

// note: Local and remote are separate worlds, and the storage commands disagree on which is the default. wrangler kv key put hits the real remote namespace unless you pass --local, while wrangler d1 execute and wrangler d1 migrations apply only touch the emulated local database unless you pass --remote. Both failure modes are silent: one quietly writes to production, the other reports success while production stays untouched.

### Deploy it

wrangler deploy
wrangler deploy --dry-run --outdir ./dist
wrangler rollback --message "revert bad deploy"

deploy bundles the entry point, uploads it, and routes 100% of production traffic to that build in one step. --dry-run --outdir runs the same build without shipping — a cheap preflight for config errors and bundle size. When a release goes wrong, wrangler rollback reverts to the previous deployment; it only moves the version pointer, so it will not undo a secret change or a migration.

# Give the Worker a real URL
wrangler deploy --domain api.example.com
wrangler deploy --route "example.com/api/*"

A custom domain points an entire hostname at the Worker and provisions TLS — use it when the Worker is the site at that hostname. A route matches a URL pattern inside a zone you already proxy through Cloudflare, so the Worker intercepts some paths and the rest hit your origin.

### Watch it in production

wrangler tail
wrangler tail --status error --method POST --search "checkout"
wrangler tail --format json | jq 'select(.outcome=="exception")'

tail streams live requests and console.log output straight from the edge. It cannot see wrangler dev — local requests print in the dev terminal. The raw stream is a firehose on a busy Worker, so filter or add --sampling-rate. With environments the deployed script is named <name>-<env>, so pass -e production or you'll watch a Worker that gets no traffic.

## Vars, secrets, and environments

Configuration splits in two. Non-sensitive values go in [vars]: plaintext, committed to git, baked into the deploy — feature flags, a public base URL, an environment name. Anything sensitive goes through wrangler secret, which encrypts the value at Cloudflare so it never lands in your repo.

wrangler secret put API_KEY
wrangler secret put API_KEY --env production
wrangler secret list --format pretty
wrangler secret bulk .env

Both surface identically on env inside the Worker, which is exactly why you should never use one name for both. Setting a secret deploys a new version of the Worker.

// note: Secrets are write-only. wrangler secret list returns names and types, never values, so a lost value can only be rotated, not recovered. And in CI, echo "$VALUE" | wrangler secret put API_KEY stores a trailing newline as part of the secret, breaking auth in a way that looks nothing like a newline problem. Use echo -n or printf %s.

### Environments

# wrangler.toml
[env.staging]
name = "my-worker-staging"
vars = { API_ENV = "staging" }

[env.production]
name = "my-worker-production"
vars = { API_ENV = "production" }
wrangler deploy --env production
wrangler secret put API_KEY --env production
wrangler dev --env staging

An environment is a separately-named Worker with its own vars, routes, bindings, and secret store. That last part catches everyone: a secret set without --env does not exist for --env production — different Workers, different stores — so set each value once per environment or the binding is missing at runtime. --env also picks the matching local file: wrangler dev --env staging reads .dev.vars.staging.

Local dev reads secrets from a .dev.vars file of KEY=VALUE lines in the project root. Wrangler loads it automatically and never uploads it, so gitignore it. If prod and staging live in separate config files instead of [env] blocks, pick one with -c wrangler.staging.toml-c swaps the whole file, --env selects a block inside one.

## Storage bindings

Three stores cover most Workers, and all follow the same shape: create the resource with wrangler, paste the binding into your config, read it off env in code.

### KV — config and cache

Workers KV is a globally replicated, eventually consistent key-value store, tuned for data read far more often than it's written: feature flags, config, edge-cached HTML.

wrangler kv namespace create SESSIONS

# paste the printed id into wrangler.toml:
# [[kv_namespaces]]
# binding = "SESSIONS"
# id = "<id>"

wrangler kv key put "user:1" active --binding=SESSIONS
wrangler kv key list --binding=SESSIONS --prefix "user:"
wrangler kv key get "user:1" --binding=SESSIONS --text

namespace create prints the id and a ready-to-paste config block — it wires up nothing for you, and the binding name in code must match the config exactly. A write is visible immediately where it was made but takes up to ~60 seconds to reach every edge location; the minimum TTL is 60 seconds too.

### R2 — object storage

R2 is S3-compatible object storage with zero egress fees: user uploads, images, backups, anything you would otherwise put in a bucket.

wrangler r2 bucket create my-bucket
wrangler r2 object put my-bucket/images/logo.png --file=./logo.png --content-type=image/png --remote
wrangler r2 object get my-bucket/images/logo.png --file=./logo.png --remote

Note the --remote on the object commands — without it they read and write a local simulated bucket. object put is built for small files and one-offs; for large or bulk uploads use the S3-compatible API through the aws CLI or rclone, which do multipart.

### D1 — SQL at the edge

D1 is a serverless SQLite database you create and own inside Cloudflare, with versioned migrations and a 30-day time-travel history.

wrangler d1 create my-db
wrangler d1 execute my-db --local --file=./schema.sql
wrangler d1 migrations create my-db "add_users_table"
wrangler d1 migrations apply my-db --remote

If your data already lives in a Postgres you host elsewhere, use Hyperdrive instead — it owns no data, it pools connections and caches reads in front of your existing database.

## Pages for static sites

Pages hosts a built directory. Create the project once, then ship the output of your build.

wrangler pages project create my-app
wrangler pages deploy ./dist --project-name my-app

Put the project name and output directory in the config and the flags go away.

# wrangler.toml for a Pages project
name = "my-app"
pages_build_output_dir = "dist"

Production versus preview is decided by branch name, not by a flag. Deploying to the project's production branch updates your live domain; any other branch gets its own *.pages.dev preview URL with a separate set of preview secrets and bindings.

wrangler pages deploy ./dist --project-name my-app --branch feature-login
wrangler pages deployment list --project-name my-app
wrangler pages deployment tail --project-name my-app

Pages secrets have their own subcommands and the same prod/preview split — wrangler pages secret put DATABASE_URL --project-name my-app. Local dev is wrangler pages dev ./dist, which serves assets and Functions together and takes --kv, --d1, and --r2 to bind storage by name. Only Functions emit logs, so a purely static site has nothing to tail.

// note: Custom domains for a Pages project are attached in the Cloudflare dashboard, not from the CLI — there is no wrangler pages domain add. Same for DNS records, redirect rules, and API tokens. Before you go clicking, confirm the dashboard is logged into the same account wrangler whoami reports, or you'll hunt for a project that was deployed somewhere else.

## Deploying from CI

Interactive login is useless on a runner. Set two environment variables instead and wrangler authenticates itself with no browser involved.

# .github/workflows/deploy.yml
- run: npx wrangler pages deploy ./dist --project-name my-app --branch "$GITHUB_REF_NAME" --commit-hash "$GITHUB_SHA" --commit-dirty=true
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

A CLOUDFLARE_API_TOKEN in the environment always wins over a cached OAuth login, which is what you want on a shared runner. CLOUDFLARE_ACCOUNT_ID disambiguates when the token can see more than one account, and --commit-dirty=true suppresses the dirty-working-tree warning CI checkouts trigger.

Create the token with the narrowest scope that works, adding permissions only when a deploy fails without them:

  • Workers deploys need Account: Workers Scripts: Edit.
  • Pages deploys need Account: Cloudflare Pages: Edit.
  • Add Account: Workers KV Storage: Edit or Account: D1: Edit only if the job seeds a namespace or applies a migration.
  • Skip zone-level permissions unless the job attaches routes or custom domains.

Guard the job with a preflight: wrangler whoami --json exits non-zero on missing or invalid credentials, so a bad token fails fast instead of halfway through a deploy.

wrangler whoami --json

## The mental model

Your wrangler config is the source of truth for everything about the Worker: name, entry point, compatibility date, vars, environments, and every binding it can reach. It lives in git, it's reviewable in a diff, and it's exactly what CI reads. Secrets are the deliberate exception — they exist only at Cloudflare, set out of band, never readable back.

The dashboard is for the things that aren't code: DNS records, custom domains, zone settings, API tokens, account membership. Everything else — build, dev, deploy, roll back, seed a namespace, run a migration, tail logs — belongs in the CLI where it can be scripted. When a deploy behaves strangely, the checklist is short and almost always enough: wrangler whoami for the account, --env for the target, --local versus --remote for the data, and wrangler tail for what actually happened.

// related cheat sheets