In Sandbox SDK 0.12, ContainerProxy applies the outbound fields of your Sandbox class to each request that the container sends. In 1.0, your Durable Object registers your own WorkerEntrypoint for HTTP and HTTPS requests from the container, and the entrypoint applies the same rules in the same order.
Your 1.0 class replaces the 0.12 Sandbox class, and has the container getter, the ensureRunning() and startContainer() methods, and the ENV and INACTIVITY_TIMEOUT_MS constants from Replace the Sandbox class. The entrypoint uses this.ctx.exports, which needs compatibility date 2025-11-17 or later, or the enable_ctx_exports flag. 0.12 outbound rules needed it too.
This page replaces enableInternet, interceptHttps, allowedHosts, deniedHosts, outbound, outboundByHost, outboundHandlers, their aliases outboundProxy and outboundProxies, the runtime setters such as allowHost() and setOutboundByHost(), and gitCheckout(). Remove ContainerProxy from your imports and from the exports of your Worker. For the handlers that 0.12 added for bucket mounts, refer to Move bucket mounts.
-
Copy the outbound fields of your 0.12 class into constants, and give each handler a name:
src/index.tsts type Handler = { method: string; params?: unknown }; type OutboundHandler = ( request: Request, env: Env, params: unknown, ) => Response | Promise<Response>; // The outbound fields of your 0.12 class. const ENABLE_INTERNET = false; const ALLOWED_HOSTS: string[] | undefined = [ "github.com", "registry.npmjs.org", "api.example.com", ]; const DENIED_HOSTS: string[] | undefined = undefined; const OUTBOUND_BY_HOST: Record<string, Handler> = { "api.example.com": { method: "withToken" }, }; const OUTBOUND: Handler | undefined = undefined; // Your 0.12 handlers, by name. const HANDLERS: Record<string, OutboundHandler> = { withToken: (request, env) => { const headers = new Headers(request.headers); headers.set("Authorization", `Bearer ${env.API_TOKEN}`); return fetch(new Request(request, { headers })); }, };If your 0.12 class did not set
enableInternet, setENABLE_INTERNETtotrue, the 0.12 default. For a field that your class did not set, use{}forOUTBOUND_BY_HOSTandundefinedfor the other constants.Put every function from
outboundHandlers,outboundByHost, andoutboundinHANDLERS, and refer to them by name inOUTBOUND_BY_HOSTandOUTBOUND. A 0.12 handler takes(request, env, ctx). Change it to take(request, env, params), and readparamswhere it readctx.params. -
Add
WorkerEntrypointto yourcloudflare:workersimport, and add the entrypoint, which checks each request in the order that 0.12 used:src/index.tsts import { WorkerEntrypoint } from "cloudflare:workers"; // The shape that 0.12 stored under OUTBOUND_CONFIGURATION. type StoredRules = { outboundByHostOverrides?: Record<string, Handler>; outboundHandlerOverride?: Handler; allowedHosts?: string[]; deniedHosts?: string[]; }; // "*" matches any run of characters, as in 0.12. function matches(pattern: string, hostname: string): boolean { const escaped = pattern .split("*") .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")); return new RegExp(`^${escaped.join(".*")}$`).test(hostname); } function lookup( handlers: Record<string, Handler> | undefined, hostname: string, ): Handler | undefined { return ( handlers?.[hostname] ?? Object.entries(handlers ?? {}).find(([pattern]) => matches(pattern, hostname), )?.[1] ); } export class Outbound extends WorkerEntrypoint<Env, StoredRules> { async fetch(request: Request): Promise<Response> { const rules = this.ctx.props; const allowed = rules.allowedHosts ?? ALLOWED_HOSTS; const denied = rules.deniedHosts ?? DENIED_HOSTS; const url = new URL(request.url); const hostname = url.hostname.replace(/\.+$/, ""); const listed = (patterns: string[]) => patterns.some((pattern) => matches(pattern, hostname)); const blocked = new Response("Origin is disallowed", { status: 520, }); if (denied && listed(denied)) { return blocked; } if (allowed && !listed(allowed)) { return blocked; } const handler = [ lookup(rules.outboundByHostOverrides, hostname), lookup(OUTBOUND_BY_HOST, hostname), rules.outboundHandlerOverride, OUTBOUND, ].find((handler) => handler && HANDLERS[handler.method]); if (handler) { const run = HANDLERS[handler.method]; return run(request, this.env, handler.params); } if (allowed || ENABLE_INTERNET) { return fetch(request); } return blocked; } }The entrypoint blocks denied hostnames, then hostnames outside the allowed list. Next, it runs the first handler that matches, in this order: a hostname handler that the sandbox set at runtime, a hostname handler from your class, the catch-all handler set at runtime, and the catch-all handler of your class. With no handler, it sends the request to the Internet if you set an allowed list or allow the Internet. A blocked request gets status
520with the bodyOrigin is disallowed, as in 0.12.The props carry the rules that the sandbox set at runtime, in the shape 0.12 stored them. Like 0.12, the entrypoint skips a handler whose name is not in
HANDLERS, such as the handlers that 0.12 stored for bucket mounts. -
Add the certificate variables to
ENV, and register the entrypoint instartContainer():src/index.tsts const RULES = "OUTBOUND_CONFIGURATION"; const CA = "/etc/cloudflare/certs/cloudflare-containers-ca.crt"; const BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; const ENV = { NODE_ENV: "test", NODE_EXTRA_CA_CERTS: CA, REQUESTS_CA_BUNDLE: BUNDLE, }; // Keep a copy of the system bundle, wait up to 10 seconds for the // certificate, then write the bundle from the copy and the certificate. const SYSTEM = "/etc/ssl/certs/ca-certificates.system.crt"; const CA_WAIT = `until [ -s ${CA} ]; do sleep 0.1; done`; const CA_READY = `timeout 10 sh -c '${CA_WAIT}'`; const TRUST = [ "sh", "-c", `[ -e ${SYSTEM} ] || { cp ${BUNDLE} ${SYSTEM}.tmp && mv ${SYSTEM}.tmp ${SYSTEM}; } ${CA_READY} && cat ${SYSTEM} ${CA} >${BUNDLE}.tmp && mv ${BUNDLE}.tmp ${BUNDLE}`, ]; export class MySandbox extends DurableObject<Env> { // ... private rules(): StoredRules { return this.ctx.storage.kv.get<StoredRules>(RULES) ?? {}; } private async intercept(): Promise<void> { const props = this.rules(); const outbound = this.ctx.exports.Outbound({ props }); await this.container.interceptAllOutboundHttp(outbound); await this.container.interceptOutboundHttps("*", outbound); } private async trust(): Promise<void> { const trust = await this.container.exec(TRUST); if ((await trust.exitCode) !== 0) { throw new Error("The container did not trust the intercept CA"); } } private async startContainer(): Promise<void> { const newContainer = !this.container.running; if (newContainer) { this.container.start({ image: this.container.images.sandbox, instance: "standard-1", env: ENV, enableInternet: ENABLE_INTERNET, }); } try { if (newContainer) { // Replaces onStart(). await this.ctx.storage.put("startedAt", Date.now()); } await this.intercept(); await this.trust(); await this.container.setInactivityTimeout( INACTIVITY_TIMEOUT_MS, ); } catch (error) { // The next request starts a new container. await this.container.destroy(); throw error; } } }// ...stands for the constructor, thecontainergetter, and your other methods.rules()reads the key where 0.12 stored the rules that each sandbox set at runtime, so those rules keep applying after the switch. If you move sandboxes side by side,copyFrom0x()copies the key into the 1.0 class.The intercepts send every request on port
80, and every HTTPS request on port443, to the entrypoint.enableInternetkeeps the 0.12 value and still decides connections on other ports. An intercept lasts until the container stops.startContainer()registers it for each new container, and again when a restarted Durable Object finds the container running. Registering it again replaces the earlier registration, so a container whose setup a deploy interrupted still gets its intercepts before the next command.Register other intercepts, such as bucket mounts, before
intercept(). A hostname intercept that you register afterintercept()never receives requests, because theOutboundentrypoint receives them. For more information, refer to If you moved outbound rules.If your class uses
DirectoryBackupfrom Move backups, callthis.backups.intercept()in thetryblock, before yourintercept(). Otherwise every backup and restore fails withBACKUP_TRANSFER, because yourOutboundentrypoint receives backup requests from the container. Call it only instartContainer(), not on every request.The HTTPS intercept signs responses with a certificate that the container does not trust by default. As in 0.12, the
TRUSTcommand adds the certificate to the system bundle, which curl and Git read.ENVsetsNODE_EXTRA_CA_CERTSandREQUESTS_CA_BUNDLEfor Node.js and Pythonrequests, which do not read the bundle by default. Keep your otherENVvalues, and passENVto eachexec()call. The image needs theca-certificatespackage, which provides the bundle.The certificate appears once the HTTPS intercept is registered.
TRUSTwaits up to 10 seconds for it. 0.12 waited up to 5 seconds. If the certificate does not appear, or the append fails,trust()throws, andstartContainer()stops the container. Each container gets a new certificate. A snapshot saves the bundle with the certificate of the container it came from, so appending to the bundle would keep certificates from earlier containers.TRUSTtherefore keeps a copy of the system bundle as it was before any certificate was added, and writes the bundle again from that copy and the current certificate. Running it again for the same container writes the same bundle.
Add methods that update the stored rules, and register the entrypoint again with them:
export class MySandbox extends DurableObject<Env> {
// ...
private async updateRules(change: StoredRules): Promise<void> {
this.ctx.storage.kv.put(RULES, { ...this.rules(), ...change });
if (this.container.running) {
await this.intercept();
}
}
async allowHost(hostname: string): Promise<void> {
const hosts = this.rules().allowedHosts ?? ALLOWED_HOSTS ?? [];
await this.updateRules({ allowedHosts: [...hosts, hostname] });
}
async setOutboundByHost(
hostname: string,
method: string,
params?: unknown,
): Promise<void> {
const hosts = { ...this.rules().outboundByHostOverrides };
hosts[hostname] = { method, params };
await this.updateRules({ outboundByHostOverrides: hosts });
}
}Registering the entrypoint again replaces the rules of a running container, and open connections pick up the new rules without being dropped. Write the other setters the same way. Each one passes a new value to updateRules():
| 0.12 | Value to pass |
|---|---|
setAllowedHosts(hosts), setDeniedHosts(hosts) |
allowedHosts or deniedHosts, set to hosts |
denyHost(), removeAllowedHost(), removeDeniedHost() |
deniedHosts or allowedHosts, with the hostname added or removed |
setOutboundByHosts(), removeOutboundByHost() |
outboundByHostOverrides, with the hostnames added or removed |
setOutboundHandler(method, params) |
outboundHandlerOverride, set to { method, params } |
Like 0.12, each list that a sandbox sets replaces the class list for that sandbox.
0.12 checked HTTPS requests only when your class set interceptHttps = true. Without it, HTTPS requests to allowed hostnames failed when enableInternet was false, and HTTPS requests to denied hostnames succeeded when it was true. The entrypoint checks every HTTPS request, so commands that relied on either behavior now follow your rules.
In 0.12, handlers that your class declared as static fields, such as static outboundByHost = { … }, never ran when TypeScript compiled class fields as definitions. That is the default for target ES2022 and later. The field skipped the setter that registers the handlers, so requests to those hostnames went to the Internet or got 520. In 1.0, the handlers run.
HTTPS requests to an IP address, such as https://203.0.113.10/, fail while * is intercepted and enableInternet is false. Use a hostname.
In 0.12, gitCheckout() clones a repository into /workspace:
const repo = "https://github.com/octocat/Hello-World";
await sandbox.gitCheckout(repo, { depth: 1 });In 1.0, run git clone in a Durable Object method:
export class MySandbox extends DurableObject<Env> {
// ...
async clone(url: string, dir: string): Promise<void> {
await this.ensureRunning();
const git = ["git", "clone", "--filter=blob:none", "--depth", "1"];
const clone = await this.container.exec(
["timeout", "-k", "5", "600", ...git, "--", url, dir],
{ cwd: "/workspace", env: ENV },
);
const output = await clone.output();
if (output.exitCode !== 0) {
throw new Error(new TextDecoder().decode(output.stderr));
}
}
}0.12 cloned with --filter=blob:none into /workspace/<repository name>, and stopped the clone after 600 seconds. Pass --branch for the branch option. To read the branch that 0.12 returned, run git branch --show-current with cwd set to the clone. The image needs Git, and your rules must allow the hostname of the Git server, such as github.com. To clone a private repository without giving the sandbox a token, refer to Clone a private repository.
After you deploy the switch, run these commands in a sandbox whose image has curl and Git. A hostname that your rules block returns the 0.12 response:
curl -s -w ' %{http_code}\n' https://example.com/Origin is disallowed 520An allowed hostname works over HTTPS without a certificate option on the command:
git ls-remote https://github.com/octocat/Hello-World HEAD7fd1a60b01f91b314f59955a4e4d4e80d8edf11d HEADIn a sandbox that called allowHost() in 0.12, requests to that hostname still succeed after the switch.