# How to use cf, the new Cloudflare CLI

> Learn cf, the new Cloudflare CLI: find commands with cf cli search, configure Workers in cloudflare.config.ts, deploy, and migrate from Wrangler.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-05 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/cloudflare-cf/

<video controls playsinline preload="metadata" poster="/images/cloudflare-cf/intro-poster.jpg" width="1920" height="1080" style="width: 100%; height: auto;">
  <source src="https://flaviocopes.com/images/cloudflare-cf/intro.mp4" type="video/mp4" />
</video>

`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](https://blog.cloudflare.com/cloudflare-cf-cli-launch/) 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](https://flaviocopes.com/cloudflare-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.

```bash
npm install --global cf
```

Check that it works:

```bash
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:

```bash
cf cli search "create a DNS record"
```

```json
[
  {
    "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:

```bash
cf schema d1 create
```

```json
{
  "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:

```bash
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:

```bash
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:

```bash
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`:

```txt
┌ 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](https://flaviocopes.com/jq-command/) without extra flags:

```bash
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:

```bash
cf dns records create --zone 023e105f4ecef8ad9ca31a8372d0c353 --body '{"type":"A","name":"test","content":"192.0.2.1","proxied":true}' --dry-run
```

```json
{
  "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:

```txt
? 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](https://flaviocopes.com/courses/cloudflare/) covers Workers, KV and the rest of the platform.

`cf init` creates a project:

```bash
cf init visitor-counter
```

It asks which package manager to use, creates the folder, installs the dependencies and generates the types:

```txt
●  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.ts` is the Worker.
- `cloudflare.config.ts` describes the Worker to Cloudflare. It replaces `wrangler.jsonc`.
- `vite.config.ts` loads the [Cloudflare Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/), which runs your code in the Workers runtime during development and builds it for deployment.
- `package.json` has `dev`, `build`, `deploy` and `typecheck` scripts that call `cf`.

Wrangler isn't in the dependencies at all.

Here's the configuration file:

```ts
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:

```ts
import { env } from 'cloudflare:workers'

export default {
  fetch() {
    return new Response(`Hello ${env.WORLD}!`)
  },
} satisfies ExportedHandler
```

## Run it locally

Start the development server:

```bash
cd visitor-counter
cf dev
```

```txt
│  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.

```bash
curl http://localhost:5173/
```

```txt
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:

```txt
┌ 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`:

```ts
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:

```ts
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:

```bash
curl http://localhost:5173/
```

```txt
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:

```bash
npm run typecheck
```

```txt
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:

```bash
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:

```bash
cf deploy --dry-run
```

```txt
├  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:

```bash
cf deploy
```

Our `VISITS` binding has no ID, so `cf deploy` creates the KV namespace first, then uploads the Worker and deploys it:

```txt
├  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:

```bash
curl https://visitor-counter.flaviocopes.workers.dev/
```

```txt
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`:

```ts
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(),
      },
    },
  }
})
```

```bash
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:

```bash
cf migrate --dry-run
cf migrate
```

This site has a small Worker that [rebuilds it on a schedule](https://flaviocopes.com/cloudflare-scheduled-rebuilds/), configured with eight cron triggers in `wrangler.jsonc`. Here's the preview on a copy of it:

```txt
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:

```ts
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:

```txt
┌ 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:

```bash
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](https://flaviocopes.com/agents-md/) or `CLAUDE.md`:

```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:

```txt
=== 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:

```ts
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](https://flaviocopes.com/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.
