1---
2title: Run a Container
3description: Run a long-lived container alongside a Durable Object, expose RPC methods, and proxy HTTP requests to ports inside the container.
4sidebar:
5 order: 11
6---
7
8Some workloads need a long-lived process — a sandboxed shell, a
9database client, a binary you can't compile to wasm. In this part
10you'll add a **Cloudflare Container** that runs alongside a Durable
11Object instance, and call into it to execute shell commands.
12
13## Declare the Sandbox class
14
15Create `src/Sandbox.ts`. A Container is declared with the **tagged
16shape** — a second type parameter that pins the public RPC surface,
17analogous to declaring an Effect `Context.Service`. The type lists
18every method callers can invoke, and the runtime implementation has
19to match. The tag takes only the container's name; configuration
20like the entrypoint file lives on `.make()` (next step), not here:
21
22```typescript
23// src/Sandbox.ts
24import * as Cloudflare from "alchemy/Cloudflare";
25import * as Effect from "effect/Effect";
26import type { PlatformError } from "effect/PlatformError";
27
28export class Sandbox extends Cloudflare.Container<
29 Sandbox,
30 {
31 exec: (command: string) => Effect.Effect<
32 { exitCode: number; stdout: string; stderr: string },
33 PlatformError
34 >;
35 }
36>()("Sandbox") {}
37```
38
39The `Cloudflare.Container<Sandbox, { ... }>()(...)` shape is the same
40ceremony as `DurableObject` — the empty `()` lets TypeScript
41capture the class identity for the typed RPC stub. Anything that
42binds `Sandbox` now sees `exec(command)` as a typed RPC method
43returning `Effect<{ exitCode, stdout, stderr }, PlatformError>`.
44
45## Add the runtime file
46
47The class above is just a typed identifier — it has no
48implementation yet. Containers always split the implementation
49into a **separate file** because the Durable Object that binds the
50container imports the class. If the runtime lived in the same
51file, the DO bundle would pull in process spawners, Node APIs,
52SDKs, etc. and Cloudflare Workers would reject it.
53
54Create `src/Sandbox.runtime.ts` with an empty `.make()` shell. The
55container's deploy-time props — `main` (the entrypoint file) and
56anything else — are the **first argument** to `.make()`; the
57runtime implementation is the second:
58
59```typescript
60// src/Sandbox.runtime.ts
61import * as Effect from "effect/Effect";
62import { Sandbox } from "./Sandbox.ts";
63
64export const SandboxLive = Sandbox.make(
65 { main: import.meta.url },
66 Effect.gen(function* () {
67 return Sandbox.of({
68 // exec + fetch will go here
69 });
70 }),
71);
72
73export default SandboxLive;
74```
75
76`Sandbox.of(...)` is an identity function carrying the container's
77typed shape — it ensures your implementation matches the interface
78you declared on the class. Right now it complains because `exec`
79is missing. Let's fill it in.
80
81## Customize per stage
82
83The container's deploy-time props can read the surrounding Stack
84through `Stack.useSync`. Pass it as `.make()`'s first argument to
85pick a beefier instance type in `prod` and the cheap `dev` instance
86everywhere else:
87
88```diff lang="typescript"
89// src/Sandbox.runtime.ts
90import * as Effect from "effect/Effect";
91+import { Stack } from "alchemy/Stack";
92import { Sandbox } from "./Sandbox.ts";
93
94export const SandboxLive = Sandbox.make(
95- { main: import.meta.url },
96+ Stack.useSync((stack) => ({
97+ main: import.meta.url,
98+ instanceType: stack.stage === "prod" ? "standard-1" : "dev",
99+ observability: { logs: { enabled: true } },
100+ })),
101 Effect.gen(function* () {
102 return Sandbox.of({
103 // exec + fetch will go here
104 });
105 }),
106);
107```
108
109`Stack.useSync` is the synchronous accessor for any data in the
110surrounding Effect context — handy for stack-level config like
111stage, app name, or anything else you'd want to vary per
112environment.
113
114To reuse matching image builds across stages, configure `publish.repository`
115in these same props; see [Share published images across stages](/cloudflare/compute/containers#share-published-images-across-stages).
116
117## Implement `exec` as an RPC method
118
119Containers have **RPC methods** — the same pattern you used on
120the `Counter` Durable Object in
121[Add a Durable Object](/cloudflare/compute/durable-objects).
122Anything you return from `Sandbox.of({ ... })` whose value is an
123`Effect` becomes a typed RPC method that callers can invoke
124through the typed handle.
125
126The interface declared on the class promised an `exec(command)`
127method. Implement it now:
128
129```diff lang="typescript"
130// src/Sandbox.runtime.ts
131import * as Effect from "effect/Effect";
132+import * as Stream from "effect/Stream";
133+import * as ChildProcess from "effect/process/ChildProcess";
134+import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
135import { Sandbox } from "./Sandbox.ts";
136
137export const SandboxLive = Sandbox.make(
138 Stack.useSync((stack) => ({ main: import.meta.url, /* ... */ })),
139 Effect.gen(function* () {
140+ const cp = yield* ChildProcessSpawner;
141
142 return Sandbox.of({
143+ exec: (command) =>
144+ cp
145+ .spawn(ChildProcess.make(command, { shell: true }))
146+ .pipe(
147+ Effect.flatMap((handle) =>
148+ Effect.all(
149+ [
150+ handle.exitCode,
151+ handle.stdout.pipe(Stream.decodeText, Stream.mkString),
152+ handle.stderr.pipe(Stream.decodeText, Stream.mkString),
153+ ],
154+ { concurrency: "unbounded" },
155+ ),
156+ ),
157+ Effect.map(([exitCode, stdout, stderr]) => ({
158+ exitCode,
159+ stdout,
160+ stderr,
161+ })),
162+ Effect.scoped,
163+ ),
164 });
165 }),
166);
167
168export default SandboxLive;
169```
170
171The body shells out via Effect's `ChildProcessSpawner` and
172collects stdout/stderr/exit code, but the **shape** is what
173matters: a function returning an `Effect` becomes a typed RPC
174method. When the Worker (via the Durable Object) calls
175`agent.exec("echo hi")`, it gets back an
176`Effect<{ exitCode, stdout, stderr }>` — exactly the type
177declared on the class.
178
179## Add an HTTP handler
180
181A container can also serve HTTP. Add a `fetch` field — Alchemy
182binds it to port `3000` inside the container by default, so any
183HTTP server you'd normally run inside Docker just works:
184
185```diff lang="typescript"
186// src/Sandbox.runtime.ts
187import * as Effect from "effect/Effect";
188import * as Stream from "effect/Stream";
189+import * as HttpServerResponse from "effect/http/HttpServerResponse";
190import * as ChildProcess from "effect/process/ChildProcess";
191import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
192import { Sandbox } from "./Sandbox.ts";
193
194export const SandboxLive = Sandbox.make(
195 Stack.useSync((stack) => ({ main: import.meta.url, /* ... */ })),
196 Effect.gen(function* () {
197 const cp = yield* ChildProcessSpawner;
198
199 return Sandbox.of({
200 exec: /* ... unchanged ... */,
201+ fetch: Effect.succeed(
202+ HttpServerResponse.text("Hello from the Sandbox container!"),
203+ ),
204 });
205 }),
206);
207```
208
209`fetch` and RPC methods like `exec` are independent — a caller
210decides which they want by either calling `getTcpPort(3000).fetch`
211(HTTP) or invoking `.exec(...)` directly (RPC). We'll use both
212later in the bonus section.
213
214## Bind the container from a Durable Object
215
216Workers don't talk to containers directly — every container has a
217**Durable Object in front of it** that owns its lifecycle. Create
218`src/Agent.ts` with the bare DO shell that imports the `Sandbox`
219class:
220
221```typescript
222// src/Agent.ts
223import * as Cloudflare from "alchemy/Cloudflare";
224import * as Effect from "effect/Effect";
225import { Sandbox } from "./Sandbox.ts";
226
227export default class Agent extends Cloudflare.DurableObject<Agent>()(
228 "Agents",
229 Effect.gen(function* () {
230 return Effect.gen(function* () {
231 return {};
232 });
233 }),
234) {}
235```
236
237Importing `Sandbox` (the class) does **not** pull in the runtime —
238that lives in `Sandbox.runtime.ts` and Rolldown tree-shakes it out.
239
240## Get the running container in the outer phase
241
242`yield* Sandbox` hands you a **running** container instance — the
243same typed shape you declared on the class, plus a `getTcpPort`
244helper. Resolve it in the **outer** Construction phase and expose `exec`
245from the inner phase as an RPC method:
246
247```diff lang="typescript"
248export default class Agent extends Cloudflare.DurableObject<Agent>()(
249 "Agents",
250 Effect.gen(function* () {
251+ const sandbox = yield* Sandbox;
252
253 return Effect.gen(function* () {
254- return {};
255+ return {
256+ exec: (command: string) => sandbox.exec(command),
257+ };
258 });
259 }),
260) {}
261```
262
263The DO instance is now a thin RPC bridge: callers invoke
264`agent.exec(cmd)`, the DO forwards to `sandbox.exec(cmd)`, and the
265captured stdout/stderr/exitCode flow back through the typed shape.
266
267## Configure the container with a Layer
268
269`yield* Sandbox` only resolves once you tell the DO **how** to run
270the container. Provide `Cloudflare.Containers.layer(Sandbox, …)` on
271the DO's constructor — that layer binds, starts, and monitors the
272container, then satisfies the `Sandbox` tag with the running
273instance:
274
275```diff lang="typescript"
276export default class Agent extends Cloudflare.DurableObject<Agent>()(
277 "Agents",
278 Effect.gen(function* () {
279 const sandbox = yield* Sandbox;
280
281 return Effect.gen(function* () {
282 return {
283 exec: (command: string) => sandbox.exec(command),
284 };
285 });
286- }),
287+ }).pipe(
288+ Effect.provide(
289+ Cloudflare.Containers.layer(Sandbox, { enableInternet: true }),
290+ ),
291+ ),
292) {}
293```
294
295:::note
296This is the same pattern as any other binding: `yield*` the class to
297get a typed handle, then `Effect.provide` the layer that configures
298it. Because `layer` resolves the `Sandbox` tag, **any**
299Effect or Layer can `yield* Sandbox` and get a running container —
300no separate bind / start / use dance.
301:::
302
303## Wire the Container into the Stack
304
305The Container's `.make()` is the side-effect that registers the
306runtime — it has to be reachable from `alchemy.run.ts`. Provide
307`SandboxLive` to the Stack via `Effect.provide`:
308
309```diff lang="typescript"
310// alchemy.run.ts
311import * as Alchemy from "alchemy";
312import * as Cloudflare from "alchemy/Cloudflare";
313import * as Effect from "effect/Effect";
314+import SandboxLive from "./src/Sandbox.runtime.ts";
315import Worker from "./src/worker.ts";
316
317export default Alchemy.Stack(
318 "MyApp",
319 {
320 providers: Cloudflare.providers(),
321 state: Cloudflare.state(),
322 },
323 Effect.gen(function* () {
324 const worker = yield* Worker;
325 return { url: worker.url };
326+ }).pipe(Effect.provide(SandboxLive)),
327);
328```
329
330The Worker binds `Agent` and exposes the `/sandbox/exec` route:
331
332```diff lang="typescript"
333// src/worker.ts
334import * as Cloudflare from "alchemy/Cloudflare";
335import * as Effect from "effect/Effect";
336+import { HttpServerRequest } from "effect/http/HttpServerRequest";
337import * as HttpServerResponse from "effect/http/HttpServerResponse";
338+import Agent from "./Agent.ts";
339
340export default Cloudflare.Worker(
341 "Worker",
342 { main: import.meta.url },
343 Effect.gen(function* () {
344+ const agents = yield* Agent;
345
346 return {
347 fetch: Effect.gen(function* () {
348+ const request = yield* HttpServerRequest;
349+
350+ if (request.url === "/sandbox/exec" && request.method === "POST") {
351+ const command = yield* request.text;
352+ const result = yield* agents
353+ .getByName("default")
354+ .exec(command)
355+ .pipe(Effect.orDie);
356+ return yield* HttpServerResponse.json(result);
357+ }
358+
359 return HttpServerResponse.text("Hello from my Worker!");
360 }),
361 };
362 }),
363);
364```
365
366## Deploy
367
368```sh
369bun alchemy deploy
370```
371
372The first deploy uploads the container image to Cloudflare and
373provisions the registry — expect it to take a minute or two longer
374than usual. The image here is built for you from the `main`
375entrypoint; to run a pre-built image instead, see
376[Bring your own image](/cloudflare/compute/containers#bring-your-own-image)
377and [Build & push images](/docker/build-and-push) in the Docker hub.
378
379## Verify
380
381Add a test that POSTs a shell command and expects the captured
382stdout in the response:
383
384```diff lang="typescript"
385// test/integ.test.ts
386import * as Cloudflare from "alchemy/Cloudflare";
387import * as Test from "alchemy/Test/Bun";
388import { expect } from "bun:test";
389import * as Effect from "effect/Effect";
390+import * as HttpBody from "effect/http/HttpBody";
391import * as HttpClient from "effect/http/HttpClient";
392import Stack from "../alchemy.run.ts";
393
394const { test, beforeAll, deploy } = Test.make({
395 providers: Cloudflare.providers(),
396 state: Cloudflare.state(),
397});
398
399const stack = beforeAll(deploy(Stack));
400
401+test(
402+ "Sandbox container executes a shell command",
403+ Effect.gen(function* () {
404+ const { url } = yield* stack;
405+
406+ const response = yield* HttpClient.post(`${url}/sandbox/exec`, {
407+ body: HttpBody.text("echo hi"),
408+ });
409+ const body = yield* response.json;
410+ expect(body).toMatchObject({ stdout: "hi\n", exitCode: 0 });
411+ }),
412+ { timeout: 60_000 },
413+);
414```
415
416```sh
417bun test test/integ.test.ts
418```
419
420The first request boots the container (which takes a few seconds —
421hence the bumped timeout); subsequent requests reuse the warm
422instance until Cloudflare evicts it for inactivity.
423
424## Bonus: HTTP requests against a container port
425
426Containers can run any HTTP server. The `Sandbox.runtime.ts` above
427exposes `fetch` on port `3000` by default. Add a `hello` RPC method
428to `Agent.ts` that proxies to it via `getTcpPort`:
429
430```diff lang="typescript"
431// src/Agent.ts
432import * as Cloudflare from "alchemy/Cloudflare";
433import * as Effect from "effect/Effect";
434+import * as HttpClientRequest from "effect/http/HttpClientRequest";
435import { Sandbox } from "./Sandbox.ts";
436
437export default class Agent extends Cloudflare.DurableObject<Agent>()(
438 "Agents",
439 Effect.gen(function* () {
440 const sandbox = yield* Sandbox;
441
442 return Effect.gen(function* () {
443 return {
444 exec: (command: string) => sandbox.exec(command),
445+ hello: () =>
446+ Effect.gen(function* () {
447+ const { fetch } = yield* sandbox.getTcpPort(3000);
448+ const response = yield* fetch(
449+ HttpClientRequest.get("http://container/"),
450+ );
451+ return yield* response.text;
452+ }),
453 };
454 });
455 }).pipe(
456 Effect.provide(
457 Cloudflare.Containers.layer(Sandbox, { enableInternet: true }),
458 ),
459 ),
460) {}
461```
462
463And the matching `/sandbox/hello` route on the Worker:
464
465```diff lang="typescript"
466// src/worker.ts (inside fetch)
467if (request.url === "/sandbox/exec" && request.method === "POST") {
468 // ... unchanged ...
469}
470
471+if (request.url === "/sandbox/hello") {
472+ const text = yield* agents
473+ .getByName("default")
474+ .hello()
475+ .pipe(Effect.orDie);
476+ return HttpServerResponse.text(text);
477+}
478```
479
480`getTcpPort(n)` returns a `Fetcher`-shaped object whose `fetch`
481method retries with backoff while the container is still booting,
482so you don't have to coordinate readiness yourself.
483
484Next you'll [add a Workflow](/cloudflare/compute/workflows) to
485orchestrate multi-step durable work that the container or DOs can
486trigger — perfect for jobs that need to outlive any single request.