How to use cf, the new Cloudflare CLI
By Flavio Copes
Learn cf, the new Cloudflare CLI: find commands with cf cli search, configure Workers in cloudflare.config.ts, deploy, and migrate from Wrangler.
cf is Cloudflare’s new command line tool. It covers the whole Cloudflare API, from DNS records and WAF rules to Workers and D1 databases, and it’s going to replace Wrangler.
Cloudflare announced it on September 28, 2026 as an open beta, so commands and configuration can still change before the stable release. I wrote this in October 2026, against the beta.
In this tutorial we’ll install it, find commands without digging through the docs, build and deploy a small Worker, and move an existing Wrangler project to it.
Why a new CLI?
Wrangler was built for Workers. Over the years it grew to about 280 commands, written by different product teams at different times. That’s why d1 info, hyperdrive get and workflows describe all mean “show me this thing”. The Cloudflare API has thousands of operations, and most of them never got a Wrangler command.
cf generates its commands from the OpenAPI schema behind the Cloudflare API, the same schema the API docs and SDKs are built from. That gives it more than 2,900 commands with consistent names, and new API features reach the CLI without someone writing a command by hand.
It’s also built for coding agents. In the announcement Cloudflare says agents now run almost half of all Wrangler usage, up from a quarter in March 2026. So cf prints JSON by default, has a search command to find the right command, and keeps the project configuration in a TypeScript file that editors and agents can type-check.
Wrangler isn’t going away right now. When the beta ends, Cloudflare will release a last major version of Wrangler that points you to cf, and keep maintaining Wrangler for 18 months after that.
Install cf
cf is an npm package and needs Node.js 22.18 or later. Bun isn’t supported, because commands that load the configuration file fail on it.
npm install --global cf
Check that it works:
cf --version
The package installs the same CLI under two names, cf and cloudflare. Use cloudflare if you already have another cf on your PATH, like the Cloud Foundry CLI.
Inside a project that lists cf as a dependency, the global command runs the project’s own copy. Everyone who works on the project, CI and your agents included, gets the same version. Projects created with cf init already include it.
cf sends anonymous usage telemetry by default. Turn it off with cf cli telemetry disable, or set DO_NOT_TRACK.
Find the command you need
With almost 3,000 commands, scrolling through --help isn’t a great way to find things. cf has a search command instead. You describe the task in plain words:
cf cli search "create a DNS record"
[
{
"command": "cf dns records create",
"summary": "Create DNS Record"
},
{
"command": "cf dns records edit",
"summary": "Update DNS Record"
},
{
"command": "cf dns records update",
"summary": "Overwrite DNS Record"
},
{
"command": "cf dns records delete",
"summary": "Delete DNS Record"
},
{
"command": "cf dns records get",
"summary": "DNS Record Details"
}
]
You get up to five matches, best first. The search runs on your machine and needs no login. Keep the task in quotes, because the command takes it as a single argument.
Once you know the command, --help lists its options. For commands generated from the API, cf schema shows the request they send. I cut the body fields down to two here:
cf schema d1 create
{
"operationId": "d1-create-database",
"httpMethod": "POST",
"path": "/accounts/{account_id}/d1/database",
"pathParams": [
{
"name": "account_id",
"type": "string",
"required": true
}
],
"queryParams": [],
"hasRequestBody": true,
"requestBodyFields": [
{
"name": "name",
"type": "string",
"required": true,
"description": "D1 database name."
},
{
"name": "read-replication-mode",
"type": "string",
"required": false,
"description": "The read replication mode for the database. Use 'auto' to create replicas and allow D1 automatically place them around the world, or 'disabled' to not use any database replicas (it can take a few hours for all replicas to be deleted)."
}
]
}
Notice read-replication-mode. In the API body it’s read_replication.mode, and cf turns nested fields into flat command options like this one.
Sign in
cf keeps its own credentials. It doesn’t reuse your Wrangler login, so you sign in once even if you already use Wrangler:
cf auth login
It prints a link and a one-time code, and opens the link in your browser, where you approve access to your account. On a server over SSH, add --no-browser and open the link on another device.
The approval page asks for hundreds of permissions, because cf covers the whole API. One sensitive scope, creating and managing API tokens, is a separate toggle that stays off unless you turn it on.
Check who you’re signed in as:
cf auth whoami
If you can access more than one Cloudflare account, cf asks which one to use the first time a command needs it, and remembers your choice for that project.
In scripts and CI you can’t open a browser, so you set an API token instead:
export CLOUDFLARE_API_TOKEN=<API_TOKEN>
export CLOUDFLARE_ACCOUNT_ID=<ACCOUNT_ID>
The token takes priority over any login. Give it only the permissions the job needs. A token that can read your account but has no Workers permission gets this when you run cf workers list:
┌ APIError
│ [10000] Authentication error
│ 403 Forbidden · HTTP /accounts/<ACCOUNT_ID>/workers/workers
└
→ Check your API token permissions
cf also reads these variables from a .env file in the current folder. Only .env, not .env.local, so check that .env is in your .gitignore.
Everything is JSON
Results go to standard output as JSON. Progress messages and errors go to standard error. So you can pipe any command into jq without extra flags:
cf zones list | jq -r '.[].name'
In Wrangler, many commands print tables and only some accept --json, which is why agents add --json to every Wrangler command and filter the result with jq. With cf you get JSON every time. It’s indented, and colored when you run it in a terminal, so you can still read it yourself.
Preview a change with —dry-run
Commands that change something accept --dry-run. It prints the request cf would send and exits. It doesn’t need credentials either:
cf dns records create --zone 023e105f4ecef8ad9ca31a8372d0c353 --body '{"type":"A","name":"test","content":"192.0.2.1","proxied":true}' --dry-run
{
"command": "cf dns records create",
"method": "POST",
"url": "https://api.cloudflare.com/client/v4/zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records",
"pathParams": {
"zone-id": "023e105f4ecef8ad9ca31a8372d0c353"
},
"query": {},
"bodyKind": "json",
"body": {
"type": "A",
"name": "test",
"content": "192.0.2.1",
"proxied": true
}
}
--body takes the JSON body of the API request. Some operations, like this one, only accept their input that way.
Use the zone ID in a dry run. The real command also accepts the domain name, like --zone flaviocopes.com, but a dry run doesn’t look it up.
Be careful with deletes. cf asks you to confirm, and the default answer is no. In a script, where it can’t ask, it gives up:
? This will permanently delete the Worker and its associated versions and deployments. Continue?
(non-interactive; pass --force to confirm)
Aborted.
Nothing is deleted, and the command still exits with status 0, so the exit code doesn’t tell your script that nothing happened. --force skips the question, but on some commands it’s also an option of the API call: cf workers delete --force also deletes a Worker that other Workers still reference. Read a command’s --help before you put --force in a script.
Create a Worker
Let’s build something. We’ll make a Worker that counts its visitors in KV. If Workers are new to you, my free Cloudflare course covers Workers, KV and the rest of the platform.
cf init creates a project:
cf init visitor-counter
It asks which package manager to use, creates the folder, installs the dependencies and generates the types:
● Creating a new Worker in /Users/flaviocopes/dev/visitor-counter
◆ Created the visitor-counter Worker in visitor-counter
│ Delegating to npm install
added 64 packages in 6s
◆ Generated types in visitor-counter/.cloudflare/types/index.d.ts
│
├ Next steps
│ cd visitor-counter
│ cf dev Start a local development server
│ cf deploy Deploy to Cloudflare
In a script, add --package-manager npm so it doesn’t ask.
These are the files you’ll work with:
src/index.tsis the Worker.cloudflare.config.tsdescribes the Worker to Cloudflare. It replaceswrangler.jsonc.vite.config.tsloads the Cloudflare Vite plugin, which runs your code in the Workers runtime during development and builds it for deployment.package.jsonhasdev,build,deployandtypecheckscripts that callcf.
Wrangler isn’t in the dependencies at all.
Here’s the configuration file:
import { bindings, defineConfig } from 'cf/config'
import * as entrypoint from './src/index.ts' with { type: 'cf-worker' }
export default defineConfig({
worker: {
name: 'visitor-counter',
compatibilityDate: '2026-10-01',
entrypoint,
env: {
WORLD: bindings.text('World'),
},
},
})
env holds the bindings, which in wrangler.jsonc were spread across vars, kv_namespaces, d1_databases and so on. Here each one is built with a function from bindings, so your editor autocompletes them and shows what each one accepts.
The import with type: 'cf-worker' tells cf where your Worker is. cf only reads the module path from it. It doesn’t run your Worker code when it loads the configuration.
The starter Worker reads the WORLD binding:
import { env } from 'cloudflare:workers'
export default {
fetch() {
return new Response(`Hello ${env.WORLD}!`)
},
} satisfies ExportedHandler
Run it locally
Start the development server:
cd visitor-counter
cf dev
│ Delegating to npx vite
➜ Local: http://localhost:5173/
cf doesn’t run a dev server itself. It hands the work to Vite, as the “Delegating to npx vite” line says, which is why the port is Vite’s 5173 and not Wrangler’s 8787.
curl http://localhost:5173/
Hello World!
Change the response in src/index.ts, save, and the next request runs the new code.
You can’t pass options through cf dev in a Vite project. cf dev --port 8788 stops with this:
┌ Error
│ Arguments cannot currently be forwarded to the detected dev command `npx
│ vite`. Run that command directly with the required arguments.
└
To change the port, set server.port in vite.config.ts.
Add a KV namespace
Now the counter. In cloudflare.config.ts, replace WORLD with a GREETING text binding, and add a KV namespace called VISITS:
import { bindings, defineConfig } from 'cf/config'
import * as entrypoint from './src/index.ts' with { type: 'cf-worker' }
export default defineConfig({
worker: {
name: 'visitor-counter',
compatibilityDate: '2026-10-01',
entrypoint,
env: {
GREETING: bindings.text('Hello'),
VISITS: bindings.kv(),
},
},
})
bindings.kv() has no ID. During development the namespace lives on your machine, in .cloudflare/state/, so you don’t create anything on Cloudflare yet.
The Worker reads the count, adds one and saves it:
import { env } from 'cloudflare:workers'
export default {
async fetch(request) {
const { pathname } = new URL(request.url)
if (pathname !== '/') {
return new Response('Not found', { status: 404 })
}
const visits = Number(await env.VISITS.get('count')) + 1
await env.VISITS.put('count', String(visits))
return new Response(`${env.GREETING}! You are visitor number ${visits}.`)
},
} satisfies ExportedHandler
It ignores every path except /. A browser also asks for /favicon.ico, and without that check every page view would count twice. On the first visit get() returns null, and Number(null) is 0.
Restart cf dev and call it:
curl http://localhost:5173/
Hello! You are visitor number 1.
Run it again and you’re visitor number 2. The count survives a restart of the dev server, because it’s saved in .cloudflare/state/.
Types come from the configuration
You never declared env.VISITS anywhere in TypeScript. The generated .cloudflare/types/index.d.ts infers the Env type from cloudflare.config.ts itself, so every binding you add is typed right away.
Make a typo, like env.VISIT, and run the typecheck script:
npm run typecheck
src/index.ts(10,37): error TS2551: Property 'VISIT' does not exist on type 'Env'. Did you mean 'VISITS'?
The script runs cf workers types && tsc. The Vite plugin also rewrites the types file every time you run cf dev or cf build.
Build and deploy
cf build builds the project without uploading anything, so it doesn’t need a login:
cf build
The result goes in .cloudflare/output/v0/, with the bundle next to a worker.config.json that describes the Worker and its bindings. Cloudflare calls this the Build Output. One CI job can build it, and another can deploy that same build later with cf deploy --prebuilt.
cf deploy builds and uploads. Run it with --dry-run first. It builds and validates the Worker, lists the bindings, and stops before sending anything:
cf deploy --dry-run
├ Deploy
│ Total Upload: 0.50 KiB / gzip: 0.32 KiB
│ Your Worker has access to the following bindings:
│ Binding Resource
│ env.VISITS KV Namespace
│ env.GREETING ("Hello") Environment Variable
│
│ --dry-run: exiting now.
│
◆ Dry run complete
Then deploy for real:
cf deploy
Our VISITS binding has no ID, so cf deploy creates the KV namespace first, then uploads the Worker and deploys it:
├ Deploy
│ Total Upload: 0.50 KiB / gzip: 0.32 KiB
│
│ The following bindings need to be provisioned:
│ Binding Resource
│ env.VISITS KV Namespace
│
│
│ Provisioning VISITS (KV Namespace)...
│ 🌀 Creating new KV Namespace "visitor-counter-visits"...
│ ✨ VISITS provisioned 🎉
│
│ Your Worker was deployed with provisioned resources. You may add the resource IDs to your config file if you wish, but future deploys will continue to work even without IDs.
│ 🎉 Resources provisioned, continuing with deployment...
│
│ Worker Startup Time: 1 ms
│ Your Worker has access to the following bindings:
│ Binding Resource
│ env.VISITS (829a8a27417643adabfe529fc97f79d5) KV Namespace
│ env.GREETING ("Hello") Environment Variable
│
│ Uploaded visitor-counter (5.05 sec)
│ Deployed visitor-counter triggers (2.40 sec)
│ https://visitor-counter.flaviocopes.workers.dev
│ Current Version ID: b67d22b0-0f17-4fc6-b9ba-c2cc891b4260
│
◆ Deploy complete
The last lines have the workers.dev URL of your Worker, and it counts from 1 just like the local version:
curl https://visitor-counter.flaviocopes.workers.dev/
Hello! You are visitor number 1.
cf deploy doesn’t write the namespace ID into cloudflare.config.ts, and it doesn’t need it. On the next deploy the binding shows up as env.VISITS (inherited), and the count keeps going from where it was. Add the ID to the config only if you want it written down. To label a version, add --message and --tag.
During the beta the build output can also show failed to connect to the docker API when Docker isn’t running. It appears even for a Worker without Containers, and the build completes anyway.
Staging and production with modes
Wrangler has env blocks that you select with --env. cf has modes. Export a function instead of an object, and it receives the mode you pass with --mode:
import { bindings, defineConfig } from 'cf/config'
import * as entrypoint from './src/index.ts' with { type: 'cf-worker' }
export default defineConfig(({ mode }) => {
const isStaging = mode === 'staging'
return {
worker: {
name: isStaging ? 'visitor-counter-staging' : 'visitor-counter',
compatibilityDate: '2026-10-01',
entrypoint,
env: {
GREETING: bindings.text(isStaging ? 'Hello from staging' : 'Hello'),
VISITS: bindings.kv(),
},
},
}
})
cf deploy --mode staging
A mode deploys a separate Worker only when it returns a different name, like here. Each mode returns a complete configuration, and nothing is inherited the way Wrangler environments inherit top-level fields. Without --mode, cf dev uses development, and cf build and cf deploy use production.
Moving a Wrangler project to cf
You don’t need to migrate to start using cf. Resource commands like cf d1 list or cf r2 buckets list work in any folder, including a Wrangler project. They don’t read wrangler.jsonc, so set CLOUDFLARE_ACCOUNT_ID or pick the account when cf asks.
What you must not do is run cf dev, cf build or cf deploy in a project that only has wrangler.jsonc. cf tries to set the project up on its own, ignores the Wrangler configuration, and can produce a Worker without your code and bindings. Convert the project first:
cf migrate --dry-run
cf migrate
This site has a small Worker that rebuilds it on a schedule, configured with eight cron triggers in wrangler.jsonc. Here’s the preview on a copy of it:
Using the Wrangler bundler because @cloudflare/vite-plugin is not declared. Pass --bundler vite to override.
Would update 4 file(s):
├─ cloudflare.config.ts
├─ wrangler.config.ts
├─ package.json
└─ package-lock.json
✔ Migration preview complete.
cf migrate keeps the Wrangler bundler unless the project already uses the Vite plugin, so you don’t have to adopt Vite to migrate. Build settings go in a generated wrangler.config.ts, which is experimental during the beta. The crons became triggers in cloudflare.config.ts, one per schedule:
triggers: [
triggers.scheduled({ schedule: '15 7 * * *' }),
triggers.scheduled({ schedule: '15 8 * * *' }),
triggers.scheduled({ schedule: '15 10 * * *' }),
// and so on for the other five
],
Two things got in the way on that copy. The first time, cf migrate refused to run, because keeping the Wrangler bundler needs Wrangler installed in the project:
┌ Error
│ Generating wrangler.config.ts requires wrangler 4.100.0 or newer because
│ earlier versions do not export wrangler/experimental-config. No local Wrangler
│ installation was found. Update Wrangler and retry the migration.
└
npm install --save-dev wrangler@4 fixes it. Then cf build failed with SyntaxError: Cannot use import statement outside a module, because the package.json had "type": "commonjs" and cloudflare.config.ts is an ES module. Setting "type": "module" fixed that, and the build output had all eight crons.
In a bigger project, cf migrate also leaves TODO(@cloudflare) comments for the parts you finish by hand, like Durable Object migrations, Workflows, Containers and package scripts. The build fails until you resolve them.
Resource commands take the IDs the API expects, not names. Where Wrangler accepted a database name, cf wants the database ID:
cf d1 query <DATABASE_ID> --sql "SELECT 1"
And cf can’t do two Wrangler jobs yet. For live logs, run npx wrangler tail <WORKER_NAME>. For a single secret, run npx wrangler secret put <SECRET_NAME> --name <WORKER_NAME>, or upload your secrets with the deploy using cf deploy --secrets-file <PATH>.
Using cf with coding agents
Agents are the reason cf exists, so the setup is short. Add one line to your user-level AGENTS.md or CLAUDE.md:
When interacting with Cloudflare, use the `cf` CLI unless the project has a
Wrangler configuration file.
An agent that has never seen cf still finds its way, because --help starts with a block written for agents:
=== STOP: AGENT COMMAND DISCOVERY ===
AGENTS: Do not explore commands by chaining nested --help calls.
Your first port of call and the best way to discover commands is:
AGENTS: Keep cf cli search queries anonymous; describe the action and resource type only.
Never include names, email addresses, domains, account or resource IDs, tokens, or other identifying values.
cf cli search "<describe the task you want to accomplish>"
So an agent searches, checks the match with --help or cf schema, previews it with --dry-run, and then runs it. If it types a command that doesn’t exist, cf lists the closest ones.
You do the sign-in yourself, because it needs a browser. For an agent that runs with nobody around, use CLOUDFLARE_API_TOKEN with a limited token. To give an agent separate credentials for one project on your own machine, create a profile with cf auth create <NAME> and bind it to the project folder with cf auth activate <NAME>.
Before you let an agent run cf on its own, remember the two traps from the dry-run section: an aborted delete exits with status 0, and --force can change what the API call does.
How I would use cf
I haven’t moved anything to cf yet. This is where I would start, and where I would wait.
The scheduled rebuild Worker would go first. It’s one file, and its eight crons are a summer and a winter version of four times of day in Rome, because Cloudflare crons run in UTC. With a TypeScript configuration I could compute them from the four times instead of listing them:
import { defineConfig, triggers } from 'cf/config'
const romeSlots = [9, 12, 15, 17]
export default defineConfig({
worker: {
name: 'flaviocopes-daily-redeploy',
compatibilityDate: '2026-08-01',
entrypoint: 'index.js',
triggers: romeSlots
.flatMap((hour) => [hour - 2, hour - 1]) // summer (UTC+2) and winter (UTC+1)
.map((hour) => triggers.scheduled({ schedule: `15 ${hour} * * *` })),
},
})
It builds the same eight crons as the list. The Worker reads its deploy hook URL from a secret, and since cf can’t set a single secret yet, I would upload it with --secrets-file on deploy.
I would also use cf for the account chores I do in the dashboard today. When I set up a Cloudflare Tunnel, cloudflared created a DNS record that it has no command to remove. With cf, that’s cf dns records list to find the record ID, then cf dns records delete.
This site stays on Wrangler for now. It’s an Astro site, and during the beta Astro 6 and later doesn’t build with cf. I’d also keep anything with Durable Objects on Wrangler until the stable release, because cf migrate doesn’t convert their migrations and that part is written by hand. And I’d want live logs in cf before I depend on it for a Worker I debug often.
Want me to talk about your product? You can sponsor this site.
Related posts about cloudflare: