Shipping a Small Deno Service Without a Build Step or a Security Blanket
Deno runs TypeScript without a build step and sandboxes every process behind explicit permission flags. Here's how that combination works for a real small service — and where it pushes back.
04 Sept 2026, 11:24 UTC

You have a small internal tool to deploy — say, a webhook receiver that writes JSON payloads to disk and calls one upstream API. In a Node.js workflow you'd reach for a TypeScript compiler, a tsconfig, node_modules, and then hope nothing in the dependency tree does something you didn't expect. Deno attacks both halves of that annoyance at once: it runs TypeScript directly, and it refuses to touch the network, filesystem, or environment unless you explicitly say so.
The thesis here is simple: for small services and automation, Deno's combination of on-the-fly TypeScript execution and a default-deny permission sandbox removes real operational work — a build pipeline and a class of "why did this script read my SSH keys?" surprises — at the cost of some friction you'll want to plan for.
TypeScript without the ceremony
Deno compiles TypeScript internally as it loads your code. There is no separate tsc step, no dist/ directory to keep in sync, and no source-map gymnastics to debug what you actually wrote. You point the runtime at a .ts file and it runs:
deno run server.tsThat's the whole build pipeline. Type-checking happens too, and you can run it standalone with deno check server.ts when you want CI to fail fast on type errors without executing anything. To see what compiler and module graph Deno is actually using, run deno info --json server.ts — it reports the dependency tree and cache locations, which is handy when something resolves differently than you expected.
The practical win isn't just speed. It's that the artifact you deploy is the artifact you read. For a 200-line internal service, deleting the transpile layer deletes an entire category of mismatch bugs.
Default-deny permissions as a deployment feature
The more interesting decision is Deno's sandbox. A Deno process starts with no access to the network, filesystem, environment variables, subprocesses, or FFI. Each capability requires a flag at launch time. This flips the usual security model: instead of auditing what a script might do, you declare what it's allowed to do, and anything else throws a PermissionDenied error.
For deployment, this doubles as living documentation. The launch command tells the next engineer exactly which resources the service touches:
deno run \
--allow-net=api.example.com \
--allow-read=/var/data/incoming \
--allow-write=/var/data/incoming \
--allow-env=WEBHOOK_SECRET \
server.tsRun this from the directory containing server.ts, as whatever unprivileged user the service runs under. Note the scoping: --allow-net is restricted to one host, file access is restricted to one directory, and only one environment variable is visible. If the code later tries to reach a different host or read ~/.aws/credentials, it fails loudly at the attempt — which is exactly what you want during an incident review.
A worked example: the webhook receiver
Here's the shape of the service, using only built-in Web-standard APIs — no npm packages at all:
const secret = Deno.env.get("WEBHOOK_SECRET");
if (!secret) throw new Error("WEBHOOK_SECRET is required");
Deno.serve({ port: 8080 }, async (req) => {
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const payload = await req.json();
const id = crypto.randomUUID();
await Deno.writeTextFile(
`/var/data/incoming/${id}.json`,
JSON.stringify(payload, null, 2),
);
const upstream = await fetch("https://api.example.com/ingest", {
method: "POST",
headers: { "content-type": "application/json", "x-hook-secret": secret },
body: JSON.stringify({ id, received: true }),
});
return new Response(JSON.stringify({ id, upstream: upstream.status }), {
headers: { "content-type": "application/json" },
});
});Three things to notice. First, fetch, crypto.randomUUID, and Deno.serve are all built in — the code you write for the server looks like the code you'd write for a browser, which matters if you share validation logic with a frontend. Second, every sensitive operation maps to a flag in the launch command above, so the permission list and the code agree with each other. Third, if you deploy this with --allow-net omitted, the first request fails with a permission error rather than silently exfiltrating data — a cheap smoke test that your sandbox is actually engaged is to deliberately launch with one flag missing and confirm the service errors instead of running.
To verify a deployment, check three things: the process starts without a PermissionDenied prompt, a POST to the endpoint produces a file in /var/data/incoming, and a request that triggers unexpected access (for example, pointing the upstream URL at a different host in a test build) is refused.
Where this model bites back
The friction is real and worth pricing in before you commit:
- Automation scripts. A general-purpose ops script that legitimately needs broad access ends up launched with something like
--allow-all(or-A), which throws away the sandbox entirely. The model shines for services with a narrow, stable resource footprint; it's less useful for kitchen-sink tooling. - Remote modules and restricted networks. Deno fetches URL imports at run time and caches them, verifying integrity with hashes. On an air-gapped or egress-filtered network, first-run fetches fail. You'll need to pre-populate the cache with
deno cache server.tsduring image build, vendor dependencies withdeno vendor, or pointDENO_DIRat a prepared cache. - Permissions are process-wide. A dependency you import gets the same permissions as your own code. The sandbox limits blast radius against the host, but it doesn't isolate third-party code from each other inside the process.
A practical way to try it
Pick one small service or scheduled script with an obvious resource footprint — one port, one directory, one or two outbound hosts. Port it to Deno, write the permission flags from the code's actual behavior, and run it for a week. If the flag list stays short and stable, the model is earning its keep; if you find yourself reaching for -A, that workload is telling you it's a bad fit, and you've lost an afternoon rather than a migration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.