Guide

Deno fundamentals explained

A third-party script in a CI pipeline reads your .env, phones home over the network, and writes a file outside the project tree — and by default, Node.js allows all of that. Deno is Ryan Dahl’s second attempt at a JavaScript server runtime: same V8 engine, but rewritten in Rust with explicit permission flags, ES modules only, and TypeScript built in. You run deno run --allow-net main.ts instead of hoping npm install did not pull a compromised transitive dependency. Deno is not a drop-in Node replacement for every legacy codebase, but it is increasingly viable for new APIs, edge workers, CLIs, and internal tools where security defaults matter. This guide covers the runtime architecture, permission model, module resolution (URL imports, JSR, and npm: specifiers), deno.json configuration, HTTP with Deno.serve, testing and formatting, a Harbor Fleet webhook relay worked example, a runtime decision table, common pitfalls, and a production checklist — alongside our TypeScript fundamentals guide and REST API design guide.

What Deno is (and how it differs from Node)

Deno is a standalone JavaScript and TypeScript runtime — not a framework. Like Node, it embeds V8 and uses an event-driven async model backed by Tokio (Rust’s async runtime) instead of libuv. Unlike Node, Deno ships a formatter (deno fmt), linter (deno lint), test runner (deno test), and bundler without separate npm installs. There is no node_modules folder by default: dependencies resolve from URLs, the JSR registry, or npm packages via Deno’s native npm compatibility layer.

Design goals that shape every API

  • Secure by default — file, network, environment, and subprocess access require explicit flags or runtime grants.
  • TypeScript first.ts files run without a compile step; type-checking is opt-in via deno check.
  • Web-standard APIsfetch, Request/Response, WebSocket, and crypto.subtle match browser semantics, reducing context-switching.
  • Explicit configurationdeno.json (or deno.jsonc) centralizes tasks, imports, lint rules, and compiler options.

Deno 2.x (2024 onward) narrowed the gap with Node: improved npm and node: built-in compatibility, deno install for global CLIs, and workspace support for monorepos. Teams still on heavy native-addon stacks (old bcrypt builds, custom C++ bindings) may hit friction; greenfield services that speak HTTP and JSON are the sweet spot.

The permission model

Deno’s security model is its clearest differentiator. Without flags, a script can compute and print to stdout — nothing else. Common grants:

  • --allow-read[=path] — read files or directories (default deny).
  • --allow-write[=path] — write or create files.
  • --allow-net[=host:port] — outbound TCP/UDP; restrict to specific hosts in production manifests.
  • --allow-env[=VAR] — read environment variables (API keys, database URLs).
  • --allow-run[=cmd] — spawn subprocesses (use sparingly).
  • --allow-ffi — load foreign function interfaces (rare; high risk).

In development, --allow-all (or -A) disables the sandbox — convenient, but defeats the purpose. Production deploys should list minimal scopes: a webhook receiver needs --allow-net on its listen port and perhaps one upstream host, not blanket internet access. Deno Deploy and container entrypoints encode these grants in the platform config so operators audit permissions like firewall rules.

Compare with Node, where any required module inherits full process privileges. Deno does not eliminate supply-chain risk, but it limits blast radius when a dependency goes rogue: a library without --allow-net cannot exfiltrate data even if imported.

Modules, JSR, and npm compatibility

Deno uses ECMAScript modules onlyimport and export, no require(). Classic patterns:

// URL import (version pinned)
import { Application } from "https://deno.land/x/oak@v17.0.0/mod.ts";

// JSR — Deno's modern registry (scoped packages)
import { z } from "jsr:@std/zod@3.23.8";

// npm package — resolved and cached by Deno, no manual node_modules
import express from "npm:express@4.21.0";

JSR (JavaScript Registry) publishes TypeScript-first packages with documentation and semver. Import maps in deno.json alias long URLs to short names so refactors do not chase strings across files:

{
  "imports": {
    "@/": "./src/",
    "zod": "jsr:@std/zod@^3.23.8"
  },
  "tasks": {
    "dev": "deno run --allow-net --watch main.ts",
    "test": "deno test --allow-read"
  }
}

Deno caches remote modules in a global cache directory (overridable via DENO_DIR). Lockfiles (deno.lock) pin dependency graphs for reproducible CI — analogous to package-lock.json but capturing URL, JSR, and npm resolutions in one file. For teams migrating from Node, deno install can hydrate a local node_modules when a library still expects Node resolution semantics.

HTTP servers, std library, and tooling

Modern Deno services use Deno.serve() — a built-in HTTP/HTTPS server on web-standard Request/Response objects:

Deno.serve({ port: 8080 }, async (req) => {
  const url = new URL(req.url);
  if (url.pathname === "/health") {
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "content-type": "application/json" },
    });
  }
  return new Response("Not found", { status: 404 });
});

Frameworks like Oak (middleware stack), Hono (edge-friendly, also runs on Cloudflare Workers), and Fresh (file-based SSR with islands architecture) layer routing and templating on top. The standard library (@std/ on JSR) provides tested helpers for streams, encoding, path manipulation, and testing assertions — prefer it over copying utility snippets from blog posts.

deno test runs tests with optional permissions per file, supports snapshots, and parallelizes across cores. deno fmt and deno lint enforce consistent style without Prettier/ESLint boilerplate — though ESLint can still run via deno lint plugin compatibility when you need custom rules.

Worked example: Harbor Fleet webhook relay

Harbor Fleet operates a small dispatch service that accepts signed webhooks from a payment provider and forwards normalized events to an internal queue. The team chose Deno for three reasons: TypeScript without a build step, tight --allow-net scoping, and a 12 MB container image.

Architecture

  • IngressDeno.serve on port 8787 behind nginx TLS termination.
  • Verification — HMAC-SHA256 signature check using crypto.subtle (no extra deps).
  • Transform — map provider payload to an internal TripPaid event schema with Zod validation.
  • Egressfetch POST to the queue API (single allowed host in permissions).

Their deno.json task for production:

"tasks": {
  "start": "deno run --allow-net=0.0.0.0:8787,queue.harbor.internal --allow-env=WEBHOOK_SECRET,QUEUE_TOKEN main.ts"
}

On deploy, the CI pipeline runs deno test --allow-read (unit tests with fixture payloads), deno check (full type graph), then builds a distroless image copying only main.ts, deno.json, and deno.lock. Cold start on Deno Deploy edge is under 50 ms because there is no framework bootstrapping a massive dependency tree. When latency requirements tightened, they added idempotency keys and structured JSON logging — patterns covered in our idempotency guide and structured logging guide.

Runtime decision table

Runtime Best for Trade-offs
Deno New APIs, edge functions, secure CLIs, TypeScript services without bundlers Smaller hiring pool than Node; some npm packages need compatibility shims
Node.js LTS Largest ecosystem, native addons, existing Express/Nest codebases No permission sandbox; TypeScript needs ts-node or compile step
Bun Speed-sensitive tooling, fast test runs, drop-in Node migration experiments Younger production track record; API surface still shifting
Serverless managed (Deno Deploy, Workers) Global low-latency HTTP, minimal ops, per-request billing Vendor limits on CPU time, no long-lived background workers

Common pitfalls

  • --allow-all in production — negates Deno’s security story; scope each flag to hosts and paths.
  • Unpinned URL imports@latest URLs break CI silently; use lockfiles and semver pins.
  • Assuming full Node API paritynode:fs compatibility improves but edge cases (custom native modules) still fail.
  • Blocking the event loop — same as Node: synchronous CPU work stalls all requests; offload heavy compute.
  • Ignoring deno check in CI — runtime does not type-check by default; uncaught type errors ship to prod.
  • Mixing permission models in monorepos — document which workspace packages need net vs read flags.
  • Secrets in --allow-env without vault — permissions limit which vars are read, not exfiltration if net is wide open.

Practitioner checklist

  • Pin Deno version in CI (e.g. denoland/deno:2.1.4 Docker tag) and locally via .tool-versions or mise.
  • Commit deno.lock; run deno cache --lock=deno.lock in deploy pipelines.
  • List minimal permission flags in deno.json tasks — not scattered in README examples.
  • Run deno fmt, deno lint, deno test, and deno check on every pull request.
  • Prefer JSR and pinned npm specifiers over unpinned deno.land/x URLs.
  • Use web-standard fetch and Response for portability across Deno Deploy and containers.
  • Place services behind a reverse proxy for TLS and rate limiting.
  • Log structured JSON; handle SIGTERM by draining in-flight requests before exit.
  • Validate webhook signatures before parsing JSON bodies; reject oversized payloads at the edge.
  • Re-evaluate Node compatibility quarterly — Deno’s npm layer improves fast.

Key takeaways

  • Deno is a secure-by-default JavaScript/TypeScript runtime built on V8 and Rust with explicit permission flags.
  • ESM-only imports, JSR, and npm: specifiers replace default node_modules workflows.
  • deno.json centralizes tasks, import maps, and tooling; lockfiles keep CI reproducible.
  • Deno.serve and web-standard APIs make HTTP services portable to edge and containers.
  • Choose Deno for greenfield services where security defaults and TypeScript ergonomics outweigh legacy npm addon needs.

Related reading