1---
2title: Containers
3description: Cloudflare Containers run long-lived processes beside a Durable Object — declare a typed container class, implement its runtime in a separate file, and alchemy builds the image, pushes it, and wires the DO pairing.
4---
5
6A Cloudflare Container is a long-lived process running next to a
7[Durable Object](/cloudflare/compute/durable-objects): the DO owns the
8container's lifecycle, and callers reach the container through it.
9In alchemy a container is a class with a typed RPC surface — the
10same tagged-shape ceremony as a Durable Object — plus a runtime
11implementation that alchemy bundles into a Docker image and pushes
12to Cloudflare's registry for you.
13
14Reach for a container when a [Worker](/cloudflare/compute/workers) isn't
15enough: you need a real OS process — a sandboxed shell, a binary you
16can't compile to wasm, an HTTP server listening on a port, a
17runtime Workers doesn't offer. If you only need per-entity state
18and coordination, a plain Durable Object is lighter; if you only
19need request/response compute, a Worker is.
20
21## Declare the container class
22
23The class declares the container's name and its public RPC surface;
24configuration lives on `.make()`, not here:
25
26```typescript
27// src/Sandbox.ts
28import * as Cloudflare from "alchemy/Cloudflare";
29import type * as Effect from "effect/Effect";
30
31export class Sandbox extends Cloudflare.Container<
32 Sandbox,
33 { ping: () => Effect.Effect<string> }
34>()("Sandbox") {}
35```
36
37Anything that binds `Sandbox` sees `ping()` as a typed RPC method
38returning `Effect<string>` — no schemas, no serialization
39boilerplate, exactly like DO methods.
40
41## Implement the runtime in a separate file
42
43The runtime always lives in its own file — the DO imports the class,
44and if the implementation lived alongside it, the DO bundle would
45pull in process spawners, Node APIs, and SDKs that the Workers
46runtime rejects. `.make()` takes the deploy-time props first and the
47implementation second:
48
49```typescript
50// src/Sandbox.runtime.ts
51import * as Effect from "effect/Effect";
52import * as HttpServerResponse from "effect/http/HttpServerResponse";
53import { Sandbox } from "./Sandbox.ts";
54
55export default Sandbox.make(
56 { main: import.meta.url },
57 Effect.gen(function* () {
58 return Sandbox.of({
59 ping: () => Effect.succeed("pong"),
60 fetch: Effect.succeed(
61 HttpServerResponse.text("Hello from the container!"),
62 ),
63 });
64 }),
65);
66```
67
68`main` tells alchemy to bundle this file's Effect program and bake
69it into a generated image as the entrypoint. RPC methods and `fetch`
70are independent: `fetch` serves HTTP on port `3000` inside the
71container by default, while methods like `ping` are invoked directly
72through the typed handle.
73
74## Run it from a Durable Object
75
76Yield the class inside a DO's Construction phase and provide
77`Cloudflare.Containers.layer` — that layer binds, starts, and
78monitors the container, then hands you a running instance:
79
80```typescript
81// src/Agent.ts
82import * as Cloudflare from "alchemy/Cloudflare";
83import * as Effect from "effect/Effect";
84import { Sandbox } from "./Sandbox.ts";
85
86export default class Agent extends Cloudflare.DurableObject<Agent>()(
87 "Agents",
88 Effect.gen(function* () {
89 const sandbox = yield* Sandbox;
90
91 return Effect.gen(function* () {
92 return {
93 ping: () => sandbox.ping(),
94 };
95 });
96 }).pipe(
97 Effect.provide(
98 Cloudflare.Containers.layer(Sandbox, { enableInternet: true }),
99 ),
100 ),
101) {}
102```
103
104Importing `Sandbox` (the class) does not pull in the runtime file —
105it gets tree-shaken out of the DO's bundle. A Worker then binds
106`Agent` and calls `agents.getByName(name).ping()` like any other DO
107method.
108
109## Wire the runtime into the Stack
110
111The `.make()` default export is the side-effect that registers the
112container's runtime, so it must be reachable from `alchemy.run.ts`:
113
114```typescript
115// alchemy.run.ts
116import SandboxLive from "./src/Sandbox.runtime.ts";
117
118export default Alchemy.Stack(
119 "MyApp",
120 { providers: Cloudflare.providers(), state: Cloudflare.state() },
121 Effect.gen(function* () {
122 const worker = yield* Worker;
123 return { url: worker.url };
124 }).pipe(Effect.provide(SandboxLive)),
125);
126```
127
128On deploy, alchemy bundles the entrypoint, builds and pushes the Docker
129image as needed, and reconciles the application's scaling and runtime
130configuration. The first deploy takes a minute or two longer while the
131registry is provisioned. Builds are [cached by default](#cached-builds-by-default):
132a matching image can be reused from the registry without invoking Docker.
133
134## Proxy HTTP to a container port
135
136`getTcpPort(port)` returns a `fetch` handle for any port inside the
137container, so an HTTP server you'd normally run in Docker just
138works:
139
140```typescript
141// inside the DO's inner Effect
142const { fetch } = yield* sandbox.getTcpPort(3000);
143
144return {
145 hello: () =>
146 Effect.gen(function* () {
147 const response = yield* fetch(
148 HttpClientRequest.get("http://container/"),
149 );
150 return yield* response.text;
151 }),
152};
153```
154
155The returned `fetch` retries with backoff while the container is
156still booting, so you don't coordinate readiness yourself.
157
158## Process scope vs request scope
159
160Unlike Workers, a Container is a **real process** — and that changes
161what the instance scope means. The bundled program runs under a root
162scope that closes when the process shuts down gracefully, so
163resources acquired in the constructor (a connection kept warm across requests,
164a background consumer) are genuinely released on exit. Serverless
165runtimes only approximate this: workerd never closes its instance
166scope at all, and Lambda gets a best-effort 500 ms SIGTERM window at
167sandbox shutdown. A hard kill still skips finalizers, as in any
168process, so treat instance-level cleanup as best-effort.
169
170Each incoming request to the container's HTTP server still gets its
171own request `Scope`, released when the response settles — the same
172per-event contract as every other runtime. See
173[Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope)
174for the model across all runtimes.
175
176## Bring your own image
177
178`main` is one of three image sources. Point `context` at a directory
179with a Dockerfile to build your own image, or `image` at a pre-built
180remote image that alchemy pulls and re-pushes — no Effect bundling,
181no `.make()`, props declared inline on the class:
182
183```typescript
184export class Web extends Cloudflare.Container<Web>()("Web", {
185 context: `${import.meta.dirname}/context`,
186}) {}
187
188export class Echo extends Cloudflare.Container<Echo>()("Echo", {
189 image: "mendhak/http-https-echo:latest",
190}) {}
191```
192
193Arbitrary images expose no RPC methods — the DO talks to them purely
194over their TCP port via `getTcpPort`. To build, tag, and push that
195image as part of the same Stack, see
196[Build & push images](/docker/build-and-push) in the Docker hub.
197
198When `image` already references Cloudflare's managed registry —
199for example a digest reference pushed by CI, like
200`registry.cloudflare.com/<accountId>/app@sha256:...` — alchemy
201deploys the reference as-is and skips the docker pull and push
202entirely.
203
204## Cached builds by default
205
206Dockerfile builds, inline Dockerfiles, and Effect-native `main` builds use
207registry caching without extra configuration:
208
209```typescript
210export class Web extends Cloudflare.Container<Web>()("Web", {
211 context: `${import.meta.dirname}/context`,
212}) {}
213```
214
215The default repository is
216`registry.cloudflare.com/<account-id>/<application-physical-name>`.
217The generated physical name includes the stack, stage, and resource-instance
218identity. Updates to that application within the stage can reuse published
219images, including when reverting to previously built inputs. Replacement or
220destroy/recreate can change the physical name and start a new cache.
221
222This is resource-specific caching within a stage, not one shared repository
223for every container. To reuse images across stages or applications, choose
224a common `publish.repository` as shown below.
225
226:::caution[Pin external build inputs]
227Pin base images and downloaded dependencies. A changed upstream image tag or
228package download is outside the build context and cannot invalidate its
229content hash, so a matching cached image may still be reused.
230:::
231
232## Share published images across stages
233
234Set `publish.repository` to choose a destination repository shared by
235applications and stages in the same Cloudflare account:
236
237```typescript
238export class Web extends Cloudflare.Container<Web>()("Web", {
239 context: `${import.meta.dirname}/context`,
240 publish: { repository: "web" },
241}) {}
242```
243
244`"web"` is the repository name, not a source image, application name,
245registry host, or complete image reference. Alchemy lowercases it and adds
246the configured account ID. With the default registry host, a build uses:
247
248```text
249Published build: registry.cloudflare.com/<account-id>/web:<build-hash>
250Build cache: registry.cloudflare.com/<account-id>/web:buildcache
251Deployed image: registry.cloudflare.com/<account-id>/web@sha256:<manifest-digest>
252```
253
254The build hash identifies the inputs; the manifest digest identifies the
255published artifact. Stages using the same repository and matching build
256inputs reuse the published image without invoking Docker. Changed inputs
257produce a new hash tag in that repository and can reuse its shared inline
258build-layer cache. Each stage still has its own Container application,
259runtime settings, and running instances; only images and build layers are shared.
260
261All builds targeting a repository import reusable layers from its
262`:buildcache` tag, even when their complete input hashes differ. That tag
263points to the latest exported inline cache, not an aggregate of every image
264ever published to the repository. A finished-image cache hit leaves the
265layer-cache tag unchanged. The Container deploys the immutable digest, not
266the mutable `buildcache` tag.
267
268For an Effect-native Container, put `publish` in the props passed to
269`.make()`, alongside `main`. Omitting `publish` keeps the default cached,
270application-specific repository.
271
272For a pre-built external `image`, `publish.repository` chooses where it is
273re-published, but does not enable finished-image cache reuse. Images already
274in the target registry keep their existing repository instead of being
275copied into the requested one.
276
277## Configure the container with `env`
278
279A container is a process, not a Worker: it has no bindings, so every
280piece of configuration reaches it as an environment variable. `env`
281takes a plain map and lands each entry on the deployment, where the
282program reads it from `process.env` — the same for a generated
283(`main`) image, a `context` build, and a pre-built `image`:
284
285```typescript
286export class Api extends Cloudflare.Container<Api>()("Api", {
287 context: `${import.meta.dirname}/api`,
288 ports: [{ name: "http", port: 8080 }],
289 env: {
290 PORT: "8080",
291 LOG_LEVEL: "info",
292 },
293}) {}
294```
295
296Values are strings. Wrap a secret in `Redacted` and it stays
297encrypted in alchemy's state and out of plan output, while the
298container still reads a plain string:
299
300```typescript
301env: {
302 SESSION_KEY: Redacted.make(process.env.SESSION_KEY!),
303}
304```
305
306## Read another resource's outputs
307
308A class body is module scope, so there is nowhere to `yield*` the
309bucket, queue, or database whose output you need — and a bare
310declaration is an Effect, not a resolved handle, so
311`Uploads.bucketName` is `undefined` until something yields it. Pass
312the props as an `Effect.gen` instead; inside it, sibling resources
313resolve like anywhere else:
314
315```typescript
316import * as Effect from "effect/Effect";
317
318export const Uploads = Cloudflare.R2.Bucket("Uploads");
319
320export class Api extends Cloudflare.Container<Api>()(
321 "Api",
322 Effect.gen(function* () {
323 const uploads = yield* Uploads;
324 return {
325 context: `${import.meta.dirname}/api`,
326 env: { BUCKET_NAME: uploads.bucketName },
327 };
328 }),
329) {}
330```
331
332The reference is what orders the deploy: the bucket is created
333before the container application that reads its name.
334
335## Connect to a SQL database
336
337An **effectful** (`main`) container runs your Effect program, so it
338resolves a database capability the same way a Worker does — declare
339the connection once, then bind it:
340
341```typescript
342// src/Db.ts
343import * as Prisma from "alchemy/Prisma";
344import * as Effect from "effect/Effect";
345
346export const Connection = Effect.gen(function* () {
347 const project = yield* Prisma.Project("app", {
348 createDatabase: false,
349 region: "us-east-1",
350 });
351 const postgres = yield* Prisma.Postgres("db", {
352 project,
353 region: "us-east-1",
354 });
355 return yield* Prisma.Connection("api", { database: postgres });
356});
357```
358
359```typescript
360// src/Api.runtime.ts
361import * as Prisma from "alchemy/Prisma";
362import * as SQL from "alchemy/SQL/Postgres";
363import * as Effect from "effect/Effect";
364import * as HttpServerResponse from "effect/http/HttpServerResponse";
365import { Api } from "./Api.ts";
366import { Connection } from "./Db.ts";
367
368export default Api.make(
369 { main: import.meta.url },
370 Effect.gen(function* () {
371 const db = yield* Prisma.Connect(Connection);
372 const sql = yield* SQL.Postgres({ url: db.databaseUrl });
373
374 return Api.of({
375 fetch: Effect.gen(function* () {
376 const users = yield* sql`SELECT * FROM users`;
377 return yield* HttpServerResponse.json(users);
378 }),
379 });
380 }).pipe(Effect.provide(Prisma.ConnectBinding)),
381);
382```
383
384You never name `DATABASE_URL`. The container has no bindings — it is
385a process, not a Worker — so `Prisma.Connect` writes the connection's
386outputs onto the deployment as environment variables and reads them
387back at runtime. The capability owns both ends.
388
389Start it with outbound networking, or the container never reaches the
390database at all:
391
392```typescript
393Effect.provide(Cloudflare.Containers.layer(Api, { enableInternet: true }))
394```
395
396## Connect an arbitrary image to a database
397
398An image you brought yourself knows nothing about alchemy, so there
399is no capability to bind — you name the variable and hand it the
400connection string. Give it the provider's **pooled** endpoint
401(Prisma's `databaseUrl` already prefers it; on Neon it is
402`branch.pooledConnectionUri`, on PlanetScale `role.connectionUrlPooled`),
403since nothing else is pooling those connections on the container's
404behalf:
405
406```typescript
407// src/Web.ts
408import * as Cloudflare from "alchemy/Cloudflare";
409import * as Effect from "effect/Effect";
410import { Connection } from "./Db.ts";
411
412export class Web extends Cloudflare.Container<Web>()(
413 "Web",
414 Effect.gen(function* () {
415 const connection = yield* Connection;
416 return {
417 context: `${import.meta.dirname}/web`,
418 ports: [{ name: "http", port: 8080 }],
419 env: {
420 DATABASE_URL: connection.databaseUrl,
421 PORT: "8080",
422 },
423 };
424 }),
425) {}
426```
427
428Your image reads `DATABASE_URL` with whatever client it already
429uses — `pg`, Prisma, Rails, Django. Nothing about the container is
430alchemy-specific; only the value's provenance is.
431
432A Worker fronting the same database still binds Hyperdrive — see
433[Hyperdrive](/cloudflare/data/hyperdrive). The two coexist: point
434Hyperdrive at the *direct* origin (it pools for the Worker) and the
435container at the pooled one.
436
437Under `alchemy dev` the same wiring targets your local emulators. A
438local dev database hands out `localhost` URLs, but inside the Docker
439container `localhost` is the container itself — so the dev runtime
440rewrites loopback hosts in the container's env to
441`host.docker.localhost`. The alias deliberately keeps `localhost` in
442the hostname so clients that gate plain-HTTP on a local-looking host
443(Prisma's `prisma+postgres://` protocol, for one) keep working against
444the local dev server.
445
446On Docker Desktop that alias is Docker's `host-gateway`, which
447forwards to processes bound to `127.0.0.1` on your machine.
448
449On native Linux, `host-gateway` is the docker bridge IP
450(`172.17.0.1`). A TCP SYN to that address is host INPUT — UFW,
451firewalld, and nftables on CachyOS/Arch will drop it. Binding the
452same port on the bridge or on your LAN NIC does not skip INPUT.
453Alchemy does not go that way:
454
4551. `@prisma/dev` (and any host server you start) stay on `127.0.0.1`.
4562. A unix socket on the host forwards to that port.
4573. The workerd `<name>-proxy` sidecar bind-mounts the socket and maps
458 `host.docker.localhost` to `127.0.0.1` in the shared netns.
4594. A listener in that netns accepts `127.0.0.1:<port>` and forwards
460 through the socket.
461
462The container talks to itself. The socket crosses the boundary. UFW
463never sees a SYN to `172.17.0.1`.
464
465If the unix-socket path cannot attach (missing `nsenter`, EPERM),
466Alchemy logs this UFW hole — do not set `DEFAULT_INPUT_POLICY=ACCEPT`:
467
468```
469sudo ufw allow from 172.16.0.0/12 to any port <prisma-or-dev-ports> proto tcp
470```
471
472Neon and PlanetScale have no local emulator: even under `alchemy
473dev` the URL is a cloud host (`*.neon.tech`, `*.psdb.cloud`) and the
474rewrite is a no-op. The container still needs
475`enableInternet: true` to reach them.
476
477## Which capabilities reach a container
478
479The split follows the transport. A capability whose config travels as
480environment variables works in a container: `Prisma.Connect`, and the
481token-scoped HTTP clients for
482[R2](/cloudflare/data/r2), [KV](/cloudflare/data/kv), and
483[Queues](/cloudflare/messaging/queues) —
484`Cloudflare.R2.ReadWriteBucketHttp` and friends, which mint a scoped
485API token instead of using a native binding.
486
487A capability that *is* a workerd binding cannot:
488`Cloudflare.Hyperdrive.Connect`, `Cloudflare.D1.QueryDatabase`, and
489every `*Binding` layer resolve against the Workers runtime, which no
490container process has. Use the `*Http` layer where one exists, and
491environment variables otherwise.
492
493## Bind on an async Worker
494
495An async Worker can host a container-backed Durable Object class
496that ships as plain JavaScript — `@cloudflare/sandbox`'s `Sandbox`,
497or your own class extending `@cloudflare/containers`' `Container`.
498The class lives in the worker script:
499
500```typescript
501// src/worker.ts
502import { Container } from "@cloudflare/containers";
503
504export class Sandbox extends Container {
505 defaultPort = 8080;
506}
507```
508
509`Sandbox` is the Durable Object class Cloudflare instantiates for
510each container instance; `Container` (from `@cloudflare/containers`)
511handles the lifecycle and forwards `fetch` to the port inside it.
512
513Declare it in the stack by binding a `Cloudflare.Container` in the
514Worker's `env` — the Container is the Durable Object binding and its
515ContainerApplication together. Alchemy emits the
516`durable_object_namespace` binding, marks the class as
517container-backed in the script metadata, provisions the application,
518and attaches it to the class's namespace:
519
520```typescript
521// alchemy.run.ts
522import type { Sandbox } from "./src/worker.ts";
523
524export const Worker = Cloudflare.Worker("Worker", {
525 main: "./src/worker.ts",
526 env: {
527 Sandbox: Cloudflare.Container<Sandbox>("Sandbox", {
528 image: "docker.io/cloudflare/sandbox:0.1.3",
529 }),
530 },
531});
532```
533
534The Durable Object class name defaults to the binding name (the
535`env` key). Set `className` when the exported class is named
536differently. The type parameter (`Container<Sandbox>`) is the class
537from `worker.ts` above — it types `env.Sandbox` as
538`DurableObjectNamespace<Sandbox>` through
539[`InferEnv`](/cloudflare/compute/workers#async-workers),
540so the handler reaches the container with full types:
541
542```typescript
543// src/worker.ts
544import { getContainer } from "@cloudflare/containers";
545import type * as Cloudflare from "alchemy/Cloudflare";
546import type { Worker } from "../alchemy.run.ts";
547
548export default {
549 async fetch(request: Request, env: Cloudflare.InferEnv<typeof Worker>) {
550 return getContainer(env.Sandbox, "default").fetch(request);
551 },
552};
553```
554
555Only image-backed containers (`image`, or `context`/`dockerfile`)
556can be bound this way. An effectful (`main`) container's runtime is
557provided by its `.make()` Layer, which needs an Effect-native
558Durable Object host.
559
560## ContainerApplication
561
562Under the hood every container is backed by a
563[ContainerApplication](/providers/cloudflare/containers/containerapplication)
564— the deployed, scalable unit that carries the image, instance
565type, instance counts, and observability settings. You typically
566extend `Cloudflare.Container` rather than using it directly, but
567the same props shape (`instanceType`, `observability`, `runtime`,
568…) flows through `.make()`.
569
570## Rollouts
571
572When a deploy changes the image or configuration, running instances
573are replaced with the new version. By default the replacement is
574immediate — every instance at once. Set `rollout` to replace them in
575steps instead, so the application stays available through the update:
576
577```typescript
578export class Api extends Cloudflare.Container<Api>()("Api", {
579 main: import.meta.url,
580 instances: 4,
581 maxInstances: 4,
582 rollout: { strategy: "rolling", stepPercentage: 25 },
583}) {}
584```
585
586A `rolling` strategy replaces `stepPercentage` of the instances per
587step and advances automatically as the new instances come up. Each
588replaced instance receives `SIGTERM` and has 15 minutes to shut down
589cleanly before `SIGKILL`.
590
591Rollouts replace instances; they do not split requests between two
592image versions. The Worker and Durable Object fronting the container
593cut over immediately while instances roll, so keep the
594Worker-to-container protocol compatible across both image versions
595until the rollout completes. Request-level traffic splitting exists
596one layer up, on the Worker — see
597[Gradual deployments](/cloudflare/compute/gradual-deployments).
598
599## Where next
600
601The full walkthrough:
602
603- [Run a Container](/cloudflare/compute/run-a-container) — build the
604 sandbox above out to a shell-executing container with RPC, HTTP
605 proxying, stage-dependent config, and tests.
606
607Related:
608
609- [Durable Objects](/cloudflare/compute/durable-objects) — every container
610 is fronted by one.
611- [Workers](/cloudflare/compute/workers) — the entrypoint that reaches the
612 DO (and through it, the container).
613
614Reference:
615
616- [Container API reference](/providers/cloudflare/containers/container)
617- [ContainerApplication API reference](/providers/cloudflare/containers/containerapplication)