Run a Container
Some workloads need a long-lived process — a sandboxed shell, a database client, a binary you can’t compile to wasm. In this part you’ll add a Cloudflare Container that runs alongside a Durable Object instance, and call into it to execute shell commands.
Declare the Sandbox class
Section titled “Declare the Sandbox class”Create src/Sandbox.ts. A Container is declared with the tagged
shape — a second type parameter that pins the public RPC surface,
analogous to declaring an Effect Context.Service. The type lists
every method callers can invoke, and the runtime implementation has
to match. The tag takes only the container’s name; configuration
like the entrypoint file lives on .make() (next step), not here:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import type { PlatformError } from "effect/PlatformError";
export class Sandbox extends Cloudflare.Container< Sandbox, { shell: (command: string) => Effect.Effect< { exitCode: number; stdout: string; stderr: string }, PlatformError >; }>()("Sandbox") {}The Cloudflare.Container<Sandbox, { ... }>()(...) shape is the same
ceremony as DurableObject — the empty () lets TypeScript
capture the class identity for the typed RPC stub. Anything that
binds Sandbox now sees exec(command) as a typed RPC method
returning Effect<{ exitCode, stdout, stderr }, PlatformError>.
Add the runtime file
Section titled “Add the runtime file”The class above is just a typed identifier — it has no implementation yet. Containers always split the implementation into a separate file because the Durable Object that binds the container imports the class. If the runtime lived in the same file, the DO bundle would pull in process spawners, Node APIs, SDKs, etc. and Cloudflare Workers would reject it.
Create src/Sandbox.runtime.ts with an empty .make() shell. The
container’s deploy-time props — main (the entrypoint file) and
anything else — are the first argument to .make(); the
runtime implementation is the second:
import * as Effect from "effect/Effect";import { Sandbox } from "./Sandbox.ts";
export const SandboxLive = Sandbox.make( { main: import.meta.url }, Effect.gen(function* () { return Sandbox.of({ // exec + fetch will go here }); }),);
export default SandboxLive;Sandbox.of(...) is an identity function carrying the container’s
typed shape — it ensures your implementation matches the interface
you declared on the class. Right now it complains because exec
is missing. Let’s fill it in.
Customize per stage
Section titled “Customize per stage”The container’s deploy-time props can read the surrounding Stack
through Stack.useSync. Pass it as .make()’s first argument to
pick a beefier instance type in prod and the cheap dev instance
everywhere else:
import * as Effect from "effect/Effect";import { Stack } from "alchemy/Stack";import { Sandbox } from "./Sandbox.ts";
export const SandboxLive = Sandbox.make( { main: import.meta.url }, 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 + fetch will go here }); }),);Stack.useSync is the synchronous accessor for any data in the
surrounding Effect context — handy for stack-level config like
stage, app name, or anything else you’d want to vary per
environment.
To reuse matching image builds across stages, configure publish.repository
in these same props; see Share published images across stages.
Implement exec as an RPC method
Section titled “Implement exec as an RPC method”Containers have RPC methods — the same pattern you used on
the Counter Durable Object in
Add a Durable Object.
Anything you return from Sandbox.of({ ... }) whose value is an
Effect becomes a typed RPC method that callers can invoke
through the typed handle.
The interface declared on the class promised an exec(command)
method. Implement it now:
import * as Effect from "effect/Effect";import * as Stream from "effect/Stream";import * as ChildProcess from "effect/process/ChildProcess";import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";import { Sandbox } from "./Sandbox.ts";
export const SandboxLive = Sandbox.make( Stack.useSync((stack) => ({ main: import.meta.url, /* ... */ })), Effect.gen(function* () { const cp = yield* ChildProcessSpawner;
return Sandbox.of({ shell: (command) => cp .spawn(ChildProcess.make(command, { shell: true })) .pipe( Effect.flatMap((handle) => Effect.all( [ handle.exitCode, handle.stdout.pipe(Stream.decodeText, Stream.mkString), handle.stderr.pipe(Stream.decodeText, Stream.mkString), ], { concurrency: "unbounded" }, ), ), Effect.map(([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr, })), Effect.scoped, ), }); }),);
export default SandboxLive;The body shells out via Effect’s ChildProcessSpawner and
collects stdout/stderr/exit code, but the shape is what
matters: a function returning an Effect becomes a typed RPC
method. When the Durable Object calls sandbox.shell("echo hi"),
it gets back an Effect<{ exitCode, stdout, stderr }> — exactly the
type declared on the class.
Add an HTTP handler
Section titled “Add an HTTP handler”A container can also serve HTTP. Add a fetch field — Alchemy
binds it to port 3000 inside the container by default, so any
HTTP server you’d normally run inside Docker just works:
import * as Effect from "effect/Effect";import * as Stream from "effect/Stream";import * as HttpServerResponse from "effect/http/HttpServerResponse";import * as ChildProcess from "effect/process/ChildProcess";import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";import { Sandbox } from "./Sandbox.ts";
export const SandboxLive = Sandbox.make( Stack.useSync((stack) => ({ main: import.meta.url, /* ... */ })), Effect.gen(function* () { const cp = yield* ChildProcessSpawner;
return Sandbox.of({ shell: /* ... unchanged ... */, fetch: Effect.succeed( HttpServerResponse.text("Hello from the Sandbox container!"), ), }); }),);fetch and RPC methods like shell are independent — a caller
decides which they want by either calling getTcpPort(3000).fetch
(HTTP) or invoking .shell(...) directly (RPC). We’ll use both
later in the bonus section.
Bind the container from a Durable Object
Section titled “Bind the container from a Durable Object”Workers don’t talk to containers directly — every container has a
Durable Object in front of it that owns its lifecycle. Create
src/Agent.ts with the bare DO shell that imports the Sandbox
class:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { Sandbox } from "./Sandbox.ts";
export default class Agent extends Cloudflare.DurableObject<Agent>()( "Agents", Effect.gen(function* () { return Effect.gen(function* () { return {}; }); }),) {}Importing Sandbox (the class) does not pull in the runtime —
that lives in Sandbox.runtime.ts and Rolldown tree-shakes it out.
Bind the container in the outer phase
Section titled “Bind the container in the outer phase”yield* Sandbox binds the container to the Durable Object and hands
you its handle: the typed shape you declared on the class, plus
start, getTcpPort and the rest of the container API. Resolve it in
the outer Construction phase:
export default class Agent extends Cloudflare.DurableObject<Agent>()( "Agents", Effect.gen(function* () { const sandbox = yield* Sandbox;
return Effect.gen(function* () { return {}; }); }),) {}Binding never starts the container. It only registers the container with the Durable Object.
Start the container before calling it
Section titled “Start the container before calling it”Define how to start the container once, and run it at the top of every operation that needs the container:
export default class Agent extends Cloudflare.DurableObject<Agent>()( "Agents", Effect.gen(function* () { const sandbox = yield* Sandbox;
return Effect.gen(function* () { return {}; const start = sandbox.start({ enableInternet: true }); return { exec: (command: string) => start.pipe(Effect.andThen(sandbox.shell(command))), }; }); }),) {}start is idempotent: it starts the container unless it is already
running, and concurrent calls start it once. A container that stopped
or crashed since the last request starts again on the next one. The DO
is now a thin RPC bridge: callers invoke agent.exec(cmd), and the DO
starts the container and forwards to sandbox.shell(cmd).
Wire the Container into the Stack
Section titled “Wire the Container into the Stack”The Container’s .make() is the side-effect that registers the
runtime — it has to be reachable from alchemy.run.ts. Provide
SandboxLive to the Stack via Effect.provide:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import SandboxLive from "./src/Sandbox.runtime.ts";import Worker from "./src/worker.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)),);The Worker binds Agent and exposes the /sandbox/exec route:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import Agent from "./Agent.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const agents = yield* Agent;
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest;
if (request.url === "/sandbox/exec" && request.method === "POST") { const command = yield* request.text; const result = yield* agents .getByName("default") .exec(command) .pipe(Effect.orDie); return yield* HttpServerResponse.json(result); }
return HttpServerResponse.text("Hello from my Worker!"); }), }; }),);Deploy
Section titled “Deploy”bun alchemy deployThe first deploy uploads the container image to Cloudflare and
provisions the registry — expect it to take a minute or two longer
than usual. The image here is built for you from the main
entrypoint; to run a pre-built image instead, see
Bring your own image
and Build & push images in the Docker hub.
Verify
Section titled “Verify”Add a test that POSTs a shell command and expects the captured stdout in the response:
import * as Cloudflare from "alchemy/Cloudflare";import * as Test from "alchemy/Test/Bun";import { expect } from "bun:test";import * as Effect from "effect/Effect";import * as HttpBody from "effect/http/HttpBody";import * as HttpClient from "effect/http/HttpClient";import Stack from "../alchemy.run.ts";
const { test, beforeAll, deploy } = Test.make({ providers: Cloudflare.providers(), state: Cloudflare.state(),});
const stack = beforeAll(deploy(Stack));
test( "Sandbox container executes a shell command", Effect.gen(function* () { const { url } = yield* stack;
const response = yield* HttpClient.post(`${url}/sandbox/exec`, { body: HttpBody.text("echo hi"), }); const body = yield* response.json; expect(body).toMatchObject({ stdout: "hi\n", exitCode: 0 }); }), { timeout: 60_000 },);bun test test/integ.test.tsThe first request boots the container (which takes a few seconds — hence the bumped timeout); subsequent requests reuse the warm instance until Cloudflare evicts it for inactivity.
Bonus: HTTP requests against a container port
Section titled “Bonus: HTTP requests against a container port”Containers can run any HTTP server. The Sandbox.runtime.ts above
exposes fetch on port 3000 by default. Add a hello RPC method
to Agent.ts that proxies to it via getTcpPort:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpClientRequest from "effect/http/HttpClientRequest";import { Sandbox } from "./Sandbox.ts";
export default class Agent extends Cloudflare.DurableObject<Agent>()( "Agents", Effect.gen(function* () { const sandbox = yield* Sandbox;
return Effect.gen(function* () { const start = sandbox.start({ enableInternet: true }); return { exec: (command: string) => start.pipe(Effect.andThen(sandbox.shell(command))), hello: () => Effect.gen(function* () { yield* start; const { fetch } = yield* sandbox.getTcpPort(3000); const response = yield* fetch( HttpClientRequest.get("http://container/"), ); return yield* response.text; }), }; }); }),) {}And the matching /sandbox/hello route on the Worker:
// src/worker.ts (inside fetch)if (request.url === "/sandbox/exec" && request.method === "POST") { // ... unchanged ...}
if (request.url === "/sandbox/hello") { const text = yield* agents .getByName("default") .hello() .pipe(Effect.orDie); return HttpServerResponse.text(text);}getTcpPort(n) returns a port whose fetch waits until the
container’s server accepts connections, so you don’t have to
coordinate readiness yourself. It never starts the container: call
start first.
Next you’ll add a Workflow to orchestrate multi-step durable work that the container or DOs can trigger — perfect for jobs that need to outlive any single request.