Skip to content

Container

Source: src/Cloudflare/Containers/Container.ts

A Cloudflare Container that runs a long-lived process alongside a Durable Object.

Containers always use the Container Layer pattern — the class and .make() must live in separate files. A Container must be bound to a Durable Object, and the DO imports the class to get a typed handle. If the class and .make() lived in the same file, the DO’s bundle would pull in all of the container’s runtime dependencies (process spawners, Node APIs, SDKs, etc.), which would bloat the bundle and likely break the Cloudflare Workers runtime. Keeping them separate ensures the bundler only includes the tiny class in the DO’s output.

See the Platform concept page for how this fits into the async / effect / layer progression.

Define the class and .make() in separate files. The class declares the container’s identity, configuration, and typed shape. .make() provides the runtime implementation as a default export. Use Container.of to construct the typed shape — it ensures your implementation matches the methods declared on the class.

Container class

// src/Sandbox.ts — the tag carries only the name + typed shape;
// configuration lives on `.make()`.
export class Sandbox extends Cloudflare.Container<
Sandbox,
{
exec: (cmd: string) => Effect.Effect<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
>()("Sandbox") {}

Container .make()

// src/Sandbox.runtime.ts — props are the first argument to `.make()`
export default Sandbox.make(
{ main: import.meta.url },
Effect.gen(function* () {
const cp = yield* ChildProcessSpawner;
return Sandbox.of({
exec: (command) =>
cp.spawn(ChildProcess.make(command, { shell: true })).pipe(
Effect.flatMap(({ exitCode, stdout, stderr }) =>
Effect.all({
exitCode,
stdout: stdout.pipe(Stream.decodeText, Stream.mkString),
stderr: stderr.pipe(Stream.decodeText, Stream.mkString),
}),
),
Effect.scoped,
),
fetch: Effect.succeed(
HttpServerResponse.text("Hello from container!"),
),
});
}),
);

An async Worker can host a container-backed Durable Object class that ships as plain JavaScript — @cloudflare/sandbox‘s Sandbox, or your own class extending @cloudflare/containersContainer. The class lives in the worker script; Container (the npm one) handles the lifecycle and forwards fetch to the port inside the container.

The worker script exports the container-backed class

src/worker.ts
import { Container } from "@cloudflare/containers";
export class Sandbox extends Container {
defaultPort = 8080;
}

Declare it in the stack by binding a Cloudflare.Container in the Worker’s env — the Container is the Durable Object binding and its ContainerApplication together. Alchemy emits the durable_object_namespace binding, marks the class as container-backed in the script metadata, provisions the ContainerApplication, and attaches it to the class’s namespace. The Durable Object class name defaults to the binding name (the env key); set className when the exported class is named differently.

Binding the container-backed class in the stack

alchemy.run.ts
import type { Sandbox } from "./src/worker.ts";
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: {
Sandbox: Cloudflare.Container<Sandbox>("Sandbox", {
image: "docker.io/cloudflare/sandbox:0.1.3",
}),
},
});

The type parameter (Container<Sandbox>) is the class from worker.ts — it types env.Sandbox as DurableObjectNamespace<Sandbox> via Cloudflare.InferEnv, so the handler reaches the container with full types.

Reaching the container from the async handler

src/worker.ts
import { getContainer } from "@cloudflare/containers";
import type * as Cloudflare from "alchemy/Cloudflare";
import type { Worker } from "../alchemy.run.ts";
export default {
async fetch(request: Request, env: Cloudflare.InferEnv<typeof Worker>) {
return getContainer(env.Sandbox, "default").fetch(request);
},
};

A container’s image comes from one of three sources, picked by which prop you set:

  • main — bundle your Effect program into a generated image.
  • context (+ optional dockerfile) — build your own Dockerfile.
  • image — pull a pre-built remote image and re-push it.

Only the main source bundles and injects an Effect runtime — so it has a typed shape and a .make(props, impl) runtime. The other two ship an arbitrary image as-is: they have no runtime to provide, so you declare the class with its props inline and register it purely via Cloudflare.Containers.layer from the hosting Durable Object.

Effect-native image (main)

// Alchemy bundles this file's Effect program and bakes it into a
// generated image as the entrypoint.
export class Sandbox extends Cloudflare.Container<
Sandbox,
{ ping: () => Effect.Effect<string> }
>()("Sandbox") {}
export default Sandbox.make(
{ main: import.meta.url },
Effect.gen(function* () {
return Sandbox.of({
ping: () => Effect.succeed("pong"),
fetch: Effect.succeed(HttpServerResponse.text("hello")),
});
}),
);

Build your own Dockerfile (context / dockerfile)

// Alchemy builds the Dockerfile against the context directory — no
// Effect bundling, no `.make()`. `dockerfile` defaults to
// `<context>/Dockerfile`. The props are declared inline on the tag.
export class Web extends Cloudflare.Container<Web>()("Web", {
context: `${import.meta.dirname}/context`,
}) {}

Remote image (image)

// Alchemy pulls the public image and re-pushes it to Cloudflare's
// registry — no build, no bundling, no `.make()`.
export class Echo extends Cloudflare.Container<Echo>()("Echo", {
image: "mendhak/http-https-echo:latest",
}) {}

Reaching an arbitrary image’s port from a Durable Object

// `external` and `remote` images expose no RPC methods, so the DO
// talks to them purely over their TCP port via `getTcpPort`.
export class WebObject extends Cloudflare.DurableObject<WebObject>()(
"WebObject",
Effect.gen(function* () {
const web = yield* Web;
return Effect.gen(function* () {
return {
hello: () =>
Effect.gen(function* () {
const { fetch } = yield* web.getTcpPort(8080);
const res = yield* fetch(HttpClientRequest.get("http://container/"));
return yield* res.text;
}),
};
});
}).pipe(Effect.provide(Cloudflare.Containers.layer(Web))),
) {}

main is bundled with rolldown at deploy time. Top-level calls in the effect, @effect/*, alchemy, @alchemy.run/*, and @distilled.cloud/* packages receive #__PURE__ annotations by default, so anything the container program doesn’t use from those packages is tree-shaken out of the bundle. Any other package — including your own app — is left untouched unless you list it explicitly.

Treat additional packages as pure

Pass package names (or picomatch globs) via build.pure.packages to annotate them in addition to the defaults.

{
main: import.meta.url,
build: {
pure: { packages: ["my-lib", "@my-scope/*"] },
},
}

Listing a package annotates calls whose result is bound (variable initializers, exports) — safe anywhere. If a listed package also declares "sideEffects": false (or []) in its package.json, that combination opts it into full annotation: top-level calls whose result is discarded (e.g. router.on("/path", handler) registrations) are also marked pure and deleted under minification when unused. Only list a sideEffects: false package if its modules really are free of meaningful top-level side effects. The effect, alchemy, and @distilled.cloud defaults declare exactly that, on purpose — their modules are designed to be fully tree-shakeable.

Disable pure annotations

{
main: import.meta.url,
build: { pure: false },
}

The props object — the first argument to .make() — accepts main (entrypoint file), instanceType (compute size), runtime ("bun" or "node"), and observability settings. Use Stack.useSync to read the surrounding stack and pick a beefier instanceType in prod while keeping the cheap dev instance for preview environments.

export const SandboxLive = Sandbox.make(
Stack.useSync((stack) => ({
main: import.meta.url,
instanceType: stack.stage === "prod" ? "standard-1" : "dev",
observability: { logs: { enabled: true } },
})),
Effect.gen(function* () {
return Sandbox.of({ exec: (cmd) => ... });
}),
);

The .make() export default is the side-effect that registers the container’s runtime. It must be reachable from your alchemy.run.ts so the bundler emits the runtime entrypoint. Provide it on the Stack’s generator with Effect.provide.

alchemy.run.ts
import SandboxLive from "./src/Sandbox.runtime.ts";
export default Alchemy.Stack(
"MyApp",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
const worker = yield* Worker;
return { url: worker.url };
}).pipe(Effect.provide(SandboxLive)),
);

yield* Sandbox resolves a running container instance — every method declared on the container’s shape plus a getTcpPort helper. Provide Cloudflare.Containers.layer(Sandbox, …) on the DO’s init to configure how the container runs; that layer binds, starts, and monitors it and satisfies the Sandbox tag. Because only the class is imported, the runtime implementation in Sandbox.runtime.ts is tree-shaken out of the DO’s bundle.

export default class Agent extends Cloudflare.DurableObject<Agent>()(
"Agents",
Effect.gen(function* () {
const sandbox = yield* Sandbox;
return Effect.gen(function* () {
return {
exec: (cmd: string) => sandbox.exec(cmd),
};
});
}).pipe(
Effect.provide(
Cloudflare.Containers.layer(Sandbox, { enableInternet: true }),
),
),
) {}

Use getTcpPort on the running container instance to get a fetch handle for a specific port. This lets you make HTTP requests to servers running inside the container process.

export default class Agent extends Cloudflare.DurableObject<Agent>()(
"Agents",
Effect.gen(function* () {
const sandbox = yield* Sandbox;
return Effect.gen(function* () {
const { fetch } = yield* sandbox.getTcpPort(3000);
return {
health: () =>
Effect.gen(function* () {
const response = yield* fetch(
HttpClientRequest.get("http://container/health"),
);
return yield* response.text;
}),
};
});
}).pipe(
Effect.provide(
Cloudflare.Containers.layer(Sandbox, { enableInternet: true }),
),
),
) {}