← All posts
·16 min read

Claude Code with Deno: Permissions, Imports, Edge Deploy

Claude CodeDenoRuntimeWorkflow
Claude Code with Deno: Permissions, Imports, Edge Deploy

Why Deno needs a project-specific CLAUDE.md

Deno is the odd runtime out in the modern JavaScript ecosystem. It replaces Node, npm, and most of the toolchain around them with a single binary that has its own security model, its own module resolver, its own test runner, its own formatter, and its own deploy target. The result is a workflow that feels nothing like Node even when the language is identical.

Claude Code understands Deno. It knows Deno.serve, Deno.readTextFile, deno test, deno.json, the --allow-net and --allow-read permission flags, and the difference between a jsr: import and an npm: specifier. What it does not consistently know is whether your project wants the Deno-native API or the npm compatibility layer for any given task. Deno's npm compat is comprehensive enough that almost any Node code runs, which means the wrong code still works. process.env, require (under --unstable or in npm packages), Express, and dotenv all function. They just leave you with a codebase that looks like Node wearing a Deno hat.

Without a project-specific CLAUDE.md, Claude regularly writes code that is technically correct but architecturally wrong for a Deno project. It reaches for npm:express instead of Deno.serve. It imports from npm:zod when a jsr:@badrap/valita package would do the same job with zero npm overhead. It writes scripts that assume permissions are always granted, then breaks on Deno Deploy where they are not. The runtime accepts everything; the project loses its identity.

This guide covers the CLAUDE.md rules and code patterns that make Claude Code reliable for Deno. If you are new to Claude Code, the Claude Code setup guide covers installation and authentication before this.

The Deno CLAUDE.md template

The CLAUDE.md at your project root is read before every Claude Code session. For a Deno project, it needs to answer: which Deno version, which permission flags are baseline, which import sources are allowed, what the test layout looks like, and which Node-isms are banned in favour of Deno-native or web-standard APIs.

# Deno project rules

## Stack
- Deno: 2.1.x (pinned in deno.json "compilerOptions" + .tool-versions)
- TypeScript: built-in, no separate tsconfig.json (deno.json is canonical)
- Server: native Deno.serve (no Oak, no Hono, no npm:express)
- Test runner: deno test (no Jest, no Vitest, no Mocha)
- Formatter: deno fmt (no Prettier)
- Linter: deno lint (no ESLint)
- Package source: jsr.io first, npm: only when no JSR equivalent
- Lockfile: deno.lock (committed to git, regenerated on dependency changes)
- Deploy target: Deno Deploy (edge runtime, no Node)

## Project structure
- src/: application source
- src/server.ts: Deno.serve entry
- src/routes/: route handlers, plain functions
- src/lib/: shared utilities
- tests/: deno test specs (_test.ts suffix is mandatory for discovery)
- deno.json: tasks, imports, fmt and lint config
- deno.lock: pinned dependency tree, committed
- .env: local development only, never committed

## Deno-native API rules
- HTTP server: ALWAYS Deno.serve, never npm:express or oak
- File reads: ALWAYS Deno.readTextFile / Deno.readFile, never npm:fs or node:fs
- File writes: ALWAYS Deno.writeTextFile / Deno.writeFile, never npm:fs
- Env vars: ALWAYS Deno.env.get('NAME'), never process.env
- Subprocess: ALWAYS new Deno.Command(), never npm:child_process
- HTTP client: ALWAYS fetch (web standard), never npm:axios
- Crypto: ALWAYS crypto.subtle (web standard), never node:crypto
- KV: ALWAYS Deno.openKv() for state, never sqlite as a default

## Imports
- jsr:@scope/name first (verified, typed, no npm)
- npm:package-name only when JSR has no equivalent
- https: URL imports allowed but discouraged for new code (use jsr or npm:)
- All imports declared in deno.json "imports" map, never relative npm:
- No deno.land/x/ for new code (deprecated path)

## Permissions (baseline for dev)
- --allow-net=:0,api.example.com (only the ports and hosts you need)
- --allow-read=./src,./public,./.env (no blanket --allow-read)
- --allow-env=PORT,DATABASE_URL (named env vars, never blanket)
- --allow-write=./uploads,./tmp (named paths only)
- NEVER --allow-all in any committed task or script

## Running the project
- Dev: `deno task dev` (defined in deno.json with --watch and explicit perms)
- Production: `deno task start` (no --watch, same perms as dev minus write)
- Test: `deno task test` (deno test with --allow-net=:0 only)
- Lint: `deno task lint`
- Format: `deno task fmt`
- Type check: `deno task check`

## Hard rules
- NEVER use --allow-all anywhere. Permissions are named and minimal.
- NEVER use process.env. Use Deno.env.get().
- NEVER add Express, Oak, or any HTTP framework that wraps Deno.serve.
- NEVER add Jest, Vitest, or Mocha. Use deno test.
- NEVER add Prettier or ESLint. Use deno fmt and deno lint.
- NEVER edit deno.lock by hand. Run deno install or deno cache to regenerate.
- NEVER commit node_modules. Deno does not use it. If it appears, delete it.

Four rules in this template prevent the most common Claude Code failures.

The HTTP framework rule is load-bearing. Claude has a strong prior toward Express, and Deno's npm compat means import express from "npm:express" works on the first try. Without an explicit ban, asking Claude to add a route in a Deno project produces an Express app even when Deno.serve is set up two files away. Naming Express in the deny list and providing a Deno.serve reference handler fixes this.

The permission rule is the security-shaped one. Deno's default-deny permission model is the runtime's defining feature, and the worst thing Claude can do is paper over it with -A (the short flag for --allow-all). Once a script ships with -A, the project loses every safety property Deno was chosen for. The CLAUDE.md rule keeps permissions named and minimal in every committed task definition.

The import source rule decides where dependencies come from. JSR (jsr.io) is the JavaScript Registry that Deno (and Bun, Node, others) consume. Packages there are TypeScript-native, semver-aware, and verified at publish time. npm: imports work but pull from npm with all its surface area. Claude defaults to npm for any package it has seen before; the rule forces a JSR check first.

The environment variable rule sounds small but catches Claude consistently. process.env works under Deno's Node compat layer, so the build does not fail, but it is the wrong API and it confuses anyone reading the code about which runtime this is. Deno.env.get('NAME') is the native call, returns string | undefined, and plays nicely with the --allow-env=NAME permission flag. The typed return matters in strict mode: code that consumes Deno.env.get('PORT') is forced to handle the undefined case at compile time, which prevents an entire class of bugs that process.env.PORT! silently allows in Node.

The remaining structural item worth pinning is the test file suffix. Deno's discovery defaults to _test.ts and _test.tsx (underscore, not dot), with .test.ts also accepted. Mixing conventions inside one project is fine but rare in practice. Claude tends to default to .test.ts because of Jest precedent; if your project uses the underscore form, naming the convention in CLAUDE.md prevents accidental orphan files that never get discovered. The cost of getting this wrong is silent: tests exist on disk, but deno test reports zero failures because zero tests ran.

For broader CLAUDE.md structure across project types, CLAUDE.md explained covers how the file is read and how to structure it for any stack.

Deno-native APIs vs npm compatibility

Deno's npm compat shipped properly in Deno 1.28 and matured through 2.x. Almost every npm package now runs under Deno, either via the npm: specifier prefix or via the Node compat layer for transitive dependencies. The node: prefix gives you direct access to Node's standard library (node:fs, node:crypto, node:http) on top of the same VM.

The compat layer is excellent for porting existing Node code. It is the wrong default for new Deno code. Every Deno-native API uses web standards (fetch, Request, Response, crypto.subtle, URL, Headers) or sits alongside them (Deno.serve, Deno.readTextFile). Web standards are portable to Cloudflare Workers, Bun, edge runtimes, and the browser. Node APIs are not.

Claude defaults to the npm equivalent roughly half the time without explicit rules. The fix is to put the Deno-native API in CLAUDE.md and provide a reference implementation each common case can extend.

// src/server.ts
import { handleHealth } from './routes/health.ts'
import { handleUpload } from './routes/upload.ts'

const port = Number(Deno.env.get('PORT') ?? 3000)
const hostname = Deno.env.get('HOST') ?? '0.0.0.0'

Deno.serve({ port, hostname }, async (req) => {
  const url = new URL(req.url)

  if (url.pathname === '/health') {
    return handleHealth(req)
  }

  if (url.pathname === '/upload' && req.method === 'POST') {
    return handleUpload(req)
  }

  if (url.pathname === '/file') {
    try {
      const file = await Deno.readFile('./public/index.html')
      return new Response(file, {
        headers: { 'content-type': 'text/html; charset=utf-8' },
      })
    } catch (err) {
      if (err instanceof Deno.errors.NotFound) {
        return new Response('not found', { status: 404 })
      }
      throw err
    }
  }

  return new Response('not found', { status: 404 })
})

console.log(`listening on http://${hostname}:${port}`)

A few elements matter because Claude reproduces them once they exist in the codebase.

The handler signature is (req: Request) => Response | Promise<Response>. Standard web platform types, no Node IncomingMessage or ServerResponse. This is what makes the same handler shape portable to Cloudflare Workers and Bun. Claude generates handlers in this style when an example is visible; without one it defaults to Express-style (req, res) patterns that simply do not work under Deno.serve.

The error handling pattern uses Deno.errors.NotFound rather than checking err.code === 'ENOENT'. Deno's error classes are typed, which means Claude generates correct instanceof checks when it has seen one before. Without the example it falls back to string matching on error messages, which is fragile.

The environment access goes through Deno.env.get(), not process.env. With --allow-env=PORT,HOST in the dev task, only those two variables are readable. Any other access throws a PermissionDenied error at runtime, which is exactly the behaviour you want for a project that has not yet decided whether a new env var should be permitted.

The route split into ./routes/health.ts and ./routes/upload.ts is worth keeping even for tiny servers. Deno's module graph is content-addressable and aggressively cached, which means splitting handlers into small files has effectively zero runtime cost. The benefit is that Claude can edit one handler without re-reading the entire server entry, and tests can import handlers directly without spinning up a real socket. The pattern matches what edge platforms expect: each route is a small, web-standard function from Request to Response, and the server entry only routes between them.

For environment configuration patterns, the Claude Code environment variables guide covers .env handling that translates cleanly into the Deno --allow-env model.

deno.json and jsr.io imports

The deno.json file at your project root replaces package.json, tsconfig.json, and (for most projects) any framework config files. It defines tasks, the import map, formatter rules, linter rules, and compiler options in one place. Claude reads this file early in any session and uses it to ground decisions about what scripts exist and which packages are available.

{
  "name": "@my-org/api",
  "version": "1.0.0",
  "exports": "./src/server.ts",

  "tasks": {
    "dev": "deno run --watch --allow-net=:3000 --allow-read=./src,./public --allow-env=PORT,HOST,DATABASE_URL src/server.ts",
    "start": "deno run --allow-net=:3000 --allow-read=./public --allow-env=PORT,HOST,DATABASE_URL src/server.ts",
    "test": "deno test --allow-net=:0 --allow-read=./src,./tests --allow-env tests/",
    "test:watch": "deno test --watch --allow-net=:0 --allow-read=./src,./tests --allow-env tests/",
    "check": "deno check src/server.ts",
    "fmt": "deno fmt",
    "lint": "deno lint",
    "ci": "deno task lint && deno task check && deno task test"
  },

  "imports": {
    "@std/assert": "jsr:@std/assert@^1.0.0",
    "@std/http": "jsr:@std/http@^1.0.0",
    "@std/path": "jsr:@std/path@^1.0.0",
    "@std/testing": "jsr:@std/testing@^1.0.0",
    "valita": "jsr:@badrap/valita@^0.4.0",
    "drizzle-orm": "npm:drizzle-orm@^0.36.0"
  },

  "fmt": {
    "lineWidth": 100,
    "indentWidth": 2,
    "singleQuote": true,
    "semiColons": false
  },

  "lint": {
    "rules": {
      "tags": ["recommended"],
      "include": ["no-await-in-loop", "no-external-import"]
    }
  },

  "compilerOptions": {
    "strict": true,
    "lib": ["deno.window", "dom"]
  }
}

Several elements warrant attention because Claude tends to drift on them without examples.

The tasks block is where permissions live. Every task includes its own --allow-* flags, narrowly scoped. The dev task allows network on port 3000, reads from ./src and ./public, and three named env vars. The start task drops the ./src read because production should not need it. The test task allows network on port 0 (any port, for ephemeral test servers) and reads from ./src and ./tests. None of them use --allow-all. Claude reads task definitions and reuses these patterns when generating new tasks.

The imports map is the only place Claude should look up package names. jsr: specifiers come first, npm: only when JSR has no equivalent. When Claude is asked to "add validation", it should check the imports map for valita (the JSR-native option) before reaching for npm:zod. The rule in CLAUDE.md plus the example in the imports map produce this behaviour reliably.

The fmt and lint blocks make deno fmt and deno lint deterministic per-project. Without explicit settings, Claude periodically reformats files using different conventions than the rest of the codebase, which produces noisy diffs. Pinning lineWidth, indentWidth, and quote style here removes the ambiguity.

For TypeScript configuration patterns that pair with Deno's built-in TS support, the Claude Code TypeScript guide covers strict mode settings and library targets that translate to the compilerOptions block.

Permission flags and the sandbox model

Deno's security model is its single most distinctive feature. By default, a Deno script can do nothing: no network, no disk reads, no disk writes, no env access, no subprocess, no FFI. Permissions are granted with named flags at process startup, and any unpermitted operation throws a typed PermissionDenied error.

This is the feature that breaks Claude most often without explicit rules. Claude has decades of training data assuming default-permit semantics. When it writes a script that reads a file, it does not naturally consider whether the script has --allow-read for that path. The script runs locally because the developer typed deno run --allow-all script.ts once during testing. It then ships to Deno Deploy where permissions are managed by the platform and the missing flag becomes a production incident.

The CLAUDE.md rules above make this explicit, but the structural fix is to encode every command in deno.json tasks with named flags. Once tasks are the only way to run anything, the permission requirements are visible in version control and Claude has a worked example for every new task.

# Bad: works locally, broken on Deploy
deno run --allow-all src/server.ts

# Good: explicit network port, named hosts, no blanket read
deno run \
  --allow-net=:3000,api.stripe.com,api.openai.com \
  --allow-read=./public,./src \
  --allow-env=PORT,STRIPE_SECRET_KEY,OPENAI_API_KEY \
  src/server.ts

# Better: encode in deno.json and run via task
deno task start

The --allow-net flag deserves specific attention because it accepts a comma-separated allowlist of host:port pairs. --allow-net=:3000 permits listening on port 3000 only. --allow-net=api.stripe.com permits outbound calls to Stripe only. Combining them gives a server that can listen on 3000, call Stripe, and nothing else. If the same script tries to call OpenAI, it throws.

This shape matters for AI-augmented coding. Claude periodically tries to add a feature by reaching for a third-party API. With a narrow --allow-net allowlist, the new code fails at runtime until the operator deliberately adds the host. That moment is the right time to review whether a new outbound dependency is justified.

For broader patterns around what Claude should and should not have access to, the Claude Code best practices guide covers permission principles regardless of runtime.

The permission system also surfaces a useful hook in .claude/settings.local.json for Claude Code itself. Gate any deno run invocation that uses --allow-all or -A:

{
  "permissions": {
    "allow": [
      "Bash(deno task*)",
      "Bash(deno test*)",
      "Bash(deno check*)",
      "Bash(deno fmt*)",
      "Bash(deno lint*)",
      "Bash(deno install*)",
      "Bash(deno cache*)"
    ],
    "deny": [
      "Bash(deno run --allow-all*)",
      "Bash(deno run -A*)",
      "Bash(deno run --unstable*)",
      "Bash(rm deno.lock*)"
    ]
  }
}

The --allow-all and -A denies are the load-bearing entries. They prevent Claude from sidestepping the permission system in a moment of friction. The --unstable deny prevents accidental opt-in to unstable APIs that change without notice between Deno versions.

Test runner and edge deploy

deno test is the built-in test runner. It uses the same Deno.test() API as the runtime, and it discovers files matching *_test.ts, *_test.tsx, *.test.ts, and *.test.tsx recursively. There is nothing to install, nothing to configure, and the permission flags work exactly the same as for any other Deno script.

// tests/auth_test.ts
import { assertEquals, assertExists } from '@std/assert'
import { handler } from '../src/routes/auth.ts'

Deno.test('rejects requests without a token', async () => {
  const req = new Request('http://localhost/api/me')
  const res = await handler(req)
  assertEquals(res.status, 401)
})

Deno.test('accepts valid bearer tokens', async () => {
  const req = new Request('http://localhost/api/me', {
    headers: { Authorization: 'Bearer test-token' },
  })
  const res = await handler(req)
  assertEquals(res.status, 200)
  const body = await res.json()
  assertExists(body.id)
})

Deno.test({
  name: 'reads from disk only with permission',
  permissions: { read: ['./tests/fixtures'] },
  fn: async () => {
    const data = await Deno.readTextFile('./tests/fixtures/user.json')
    const user = JSON.parse(data)
    assertEquals(user.role, 'admin')
  },
})

Deno.test({
  name: 'isolates network access',
  permissions: { net: ['localhost'] },
  fn: async () => {
    // can call localhost, cannot call public hosts
    const res = await fetch('http://localhost:0/health')
    assertEquals(res.status, 200)
  },
})

The interesting feature is per-test permission scoping. The permissions option on Deno.test() runs that test in a child runtime with exactly the permissions listed, regardless of the parent process's flags. This means one test can read fixtures while the next is denied disk access entirely. The pattern is impossible in Node, awkward in Vitest, and trivial in Deno. Claude generates it correctly when it has seen one example.

The imports come from @std/assert (the official JSR-hosted assertion library), not from npm:chai or a manual if (x !== y) throw. The std library is curated by the Deno core team, semver-stable, and tree-shaken at compile time. Every test should use it.

For broader test organisation across runtimes, the Claude Code testing guide covers structure, mocking, and what to test versus skip.

The deploy story for Deno projects is Deno Deploy, the official edge platform. It runs the same Deno binary you have locally, with the same permission system, the same web-standard APIs, and the same Deno.serve entry point. There is no separate build step, no bundler, no platform-specific runtime adapter. The Deno.serve handler you wrote for local dev runs unchanged on Deploy.

# Install the Deno Deploy CLI
deno install -Arf jsr:@deno/deployctl

# Deploy a project
deployctl deploy --project=my-api src/server.ts

# Deploy with environment variables
deployctl deploy --project=my-api --env-file=.env.production src/server.ts

# Deploy a specific branch as a preview
deployctl deploy --project=my-api --prod=false src/server.ts

Deploy infers permissions from the imports and APIs your code uses. Network access is granted automatically for Deno.serve and outbound fetch. Disk reads are granted for static files in the deploy bundle. Subprocess and FFI are denied entirely (no Deno.Command, no native code). This is the runtime contract Deno Deploy enforces.

For platform-specific adjustments and CI patterns, the Claude Code deploy guide covers production workflows that pair with these deployctl commands. For edge runtime comparisons, the Claude Code Cloudflare Workers guide covers the closest non-Deno alternative.

The Deno Deploy / Cloudflare Workers choice matters for project structure. Both platforms run web-standard Request / Response handlers. Deploy gives you Deno.serve and Deno.openKv natively; Workers gives you fetch exports and Durable Objects. The handlers are almost portable between the two, which is why writing them in web-standard form rather than Node form is worth the discipline.

One Deploy-specific pattern Claude needs to learn explicitly is Deno.openKv(). Deno KV is a built-in key-value store available locally (backed by SQLite) and on Deno Deploy (backed by a globally replicated store). The same API works in both environments, which is rare for state storage. Adding a note to CLAUDE.md that Deno.openKv is the default for low-traffic state, with a fallback to npm:drizzle-orm plus a hosted Postgres for relational workloads, gives Claude a clear decision tree. Without that note, Claude reflexively pulls in a Node-style ORM and an external database for every project, even ones that would be better served by a single kv.set(['user', id], data) call.

Cold starts are the second deploy concern worth pinning. Deno Deploy isolates are small and start fast, but they still pay an import-graph penalty on first request. Keep the server entry import-light: no top-level database connections, no synchronous file reads, no heavy library imports that are only needed for one route. Defer those to the routes that need them, and the isolate spins up in single-digit milliseconds.

Hard rules summary

The patterns above reduce to a list of mandatory rules that belong at the top of every Deno CLAUDE.md:

  1. Deno-native or web-standard APIs only. No process.env, no require, no node:fs in new code.
  2. Deno.serve for HTTP. No Express, no Oak, no Hono unless explicitly added with a written rationale.
  3. jsr: imports first. npm: only when JSR has no equivalent. All imports declared in the deno.json imports map.
  4. deno.json tasks are the only sanctioned entry points. Every task carries its own narrowly scoped permission flags.
  5. Permission flags are named and minimal. No --allow-all, no -A, ever.
  6. deno.lock is committed, regenerated on dependency changes, never edited by hand.
  7. deno test for tests. No Jest, Vitest, or Mocha. Use per-test permission scopes for isolation.
  8. deno fmt and deno lint are the formatter and linter. No Prettier, no ESLint.
  9. Deno.env.get() for environment variables, paired with --allow-env=NAME in the task definition.
  10. Deno Deploy targets the same code that runs locally. No platform-specific adapters, no Node compat shims in the deploy path.

These rules prevent the most common Deno failures in Claude Code sessions. They produce a server that uses web-standard handler signatures, a permission model that fails closed instead of open, a dependency tree sourced from JSR with auditable npm fallbacks, and an edge deploy story that needs zero translation between local and production.

The deeper benefit is portability. A Deno-native codebase is one short rewrite away from running on Bun, Cloudflare Workers, or any other web-standard runtime. The CLAUDE.md rules above are the mechanism that keeps the codebase in the web-standard lane through every Claude Code session, instead of drifting into the Node-shaped middle ground where Deno's distinctive features stop paying off. Claudify includes a Deno-specific CLAUDE.md template pre-configured for Deno 2.1, the permission patterns above, and the deny rules that prevent --allow-all slippage. For runtime comparisons across the ecosystem, Claude Code with Bun covers the closest single-binary alternative, and Claude Code best practices covers the principles that translate across runtimes.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir