Containers
Sometimes a Worker isn’t enough. You need ffmpeg, a Python library, a
headless browser, or someone’s code to run in a sandbox. Cloudflare’s
Containers run any Linux image, started on demand, a few seconds from
cold, and reached from a Worker.
In forge-effect, a container belongs to a Durable Object: each object starts its own, from the images you declare, and only its handlers reach it. The images are a class, like every other resource:
import { Container } from '@forge-effect/infra';
/** The container each box has: its images, by name. */export class Site extends Container.Application<Site>()('site', { web: Container.dockerfile(new URL('./site/Dockerfile', import.meta.url)),}) {}This page builds a sandbox where each name gets its own little web
server, in its own container. It’s the app in
example/sandbox,
which has run live on Cloudflare.
The image
Section titled “The image”Anything Docker builds, for Cloudflare’s machines (linux/amd64). Ours is
tiny, busybox serving one page:
FROM busybox:1.37COPY www /wwwEXPOSE 8080CMD ["httpd", "-f", "-p", "8080", "-h", "/www"]Attach it to an object
Section titled “Attach it to an object”A box is a Durable Object. Its contract is the usual schemas:
export class BoxUnavailable extends Schema.TaggedError<BoxUnavailable>()('BoxUnavailable', { detail: Schema.String }) {}
export class Box extends Durable.Object<Box>()('box', { page: { payload: { path: Schema.String }, success: Page, error: BoxUnavailable }, running: { success: Schema.Boolean }, stop: {},}) {}Its implementation attaches the container, and its handlers use it with
yield* Site:
export const box = Box.implement( Effect.gen(function* () { const site = yield* Site; const unavailable = (error: Container.ContainerError) => new BoxUnavailable({ detail: error.message }); return Box.handlers({ page: ({ path }) => Effect.gen(function* () { // Started unless it's running; then wait for its port. yield* site.ensure({ image: 'web', port: 8080 }); yield* site.sleepAfter('2 minutes'); const response = yield* site.fetch(8080, new Request(new URL(path, 'http://site'))); const body = yield* Effect.promise(() => response.text()); return { status: response.status, type: response.headers.get('content-type') ?? 'text/plain', body }; }).pipe(Effect.mapError(unavailable)), running: () => site.running, stop: () => site.destroy.pipe(Effect.orDie), }); }), { container: Site }, // ← each box has its own);
export default Worker.make('boxes', { objects: [box] }).bind();'web' is checked: name an image Site doesn’t have, and it’s a type
error. So is using Site anywhere but in an object that attaches it:
error container-unattached example/sandbox/boxes.ts:37:16 Site is used where no object attaches it: a container is an object's own fix: attach it to the object that uses it: Room.implement(build, { container: Site }), and call the object from elsewhereAnother Worker reaches a box the way it reaches any object, by name:
(yield* Box).byName('one').page({ path: '/' }).
The short form: a server, one per name
Section titled “The short form: a server, one per name”When all an object does with its container is serve HTTP, declare it
with Container.service. It’s a Durable Object of its own: each name has
its own server, started on the first request, and every request sent to
it goes to the port as it is.
export class Preview extends Container.service<Preview>()('preview', { image: Container.dockerfile(new URL('./site/Dockerfile', import.meta.url)), port: 8080, sleepAfter: '2 minutes',}) {}A Worker hosts it, as any object, and another sends it requests:
export default Worker.make('boxes', { objects: [box, Preview.object] }).bind();
// api.ts: /previews/<name>/<path>return yield* (yield* Preview).byName(name).fetch(new Request(new URL(`/${path}`, request.url), request));The server’s own answers come back as they are, a 404 included. One that
can’t start, or doesn’t answer on its port within 30 seconds
(startup), is a 503 that says why. Requests that come while it starts
wait for the one start.
Live, a preview’s first page took 655 ms, and the next 90 ms. Declare it in a module of its own, as above: the Worker calling it imports it, and a Worker can’t import another’s module.
What a container can do
Section titled “What a container can do”yield* Site gives the object’s container:
ensure({ image, port }) |
start it unless it’s running that image, then wait for the port |
start({ image, instance, env, entrypoint, internet }) |
start it: lite and no internet by default |
ready(port) |
wait until something answers on port |
fetch(port, request) |
send it an HTTP request |
exec(command) |
run a command in it, and collect what it said |
snapshot(name) |
save its filesystem; start({ snapshot }) starts another from it |
sleepAfter(duration) |
stop it once nothing has reached it for this long |
destroy, signal(15), monitor, inspect, running |
stop it, signal it, wait for it, ask what it runs |
An object can also start Cloudflare’s own image, Debian with Node.js,
without declaring any: start({ image: Container.debian }).
Ship it
Section titled “Ship it”$ pnpm infra plan example/sandbox/app.ts --env c1sandbox (c1)+ Worker sandbox-c1-boxes: new, live at once+ container sandbox-c1-site, for Box: web (built and pushed)~ rollout: 100%+ Worker sandbox-c1-api: new, live at once~ rollout: 100%A ship builds the Dockerfile with Docker, pushes it to your account’s part of Cloudflare’s registry, waits for Cloudflare to prepare it, then uploads the version with the image’s digest in it:
$ pnpm infra ship example/sandbox/app.ts --env c1 1.33s building sandbox-c1-site-web from …/example/sandbox/site/Dockerfile 4.52s pushing sandbox-c1-site-web:256438d9e7870845 5.59s ✓ image web of sandbox-c1-site ready: sandbox-c1-site-web@sha256:256438d9e787… 6.98s ✓ uploaded version 3, with no traffic 7.78s = the application of sandbox-c1-site 8.53s ✓ deployed 0% on version 3, 100% on the previous version …24.16s ✓ shipped sandbox-c1-boxes: https://sandbox-c1-boxes.dev-sandbox-6ce.workers.devBecause the image is part of the version, it rolls out with it, step by step, behind the smoke tests, and a failed ship rolls it back with everything else. The first ship also makes the container’s application, Cloudflare’s record of it, and a failed one deletes it again.
The first page of a new box starts its container, in about a second and a half; after that, its pages take tens of milliseconds:
GET /two/ 200 1405ms Hello from a container, started by a Durable Object.GET /two/ 200 84ms Hello from a container, started by a Durable Object.infra destroy deletes the Workers, the applications (which Cloudflare
otherwise keeps after their Workers are gone), and the images the ships
pushed.
How it works, briefly
Section titled “How it works, briefly”Cloudflare offers two ways to run containers. In the default one, an application has one image and size, and changes roll out to its instances on their own schedule, apart from the Worker. In the newer durable-object one, each object starts its own container and picks its image and size in code, and the images are named in the Worker’s version. forge-effect uses the second: it’s the one where a ship can promise all or nothing.
Good to know
Section titled “Good to know”- Docker, to ship a Dockerfile.
infra checkwarns when Docker isn’t running (docker-missing). An image you push yourself needs none:Container.registry('registry.cloudflare.com/<account>/<name>@sha256:…'). - Running containers keep their image. A ship doesn’t restart them:
ensurereplaces one running an older version’s image the next time it’s called, andsleepAfterlets idle ones go. - It costs money while it runs. Containers bill for the time they run;
sleepAfteranddestroyare how you stop paying. - The durable-object policy is in beta (September 2026), and so is
this. In tests,
Testing.object(Box, box, { container: { fetch } })gives each object a pretend container. - Containers in
infra devrun in Docker: dev builds each Dockerfile image, and each object starts its own container from it, as on Cloudflare: under a second for a small image. Dev removes them when it stops. Cloudflare’s Debian image (Container.debian) runs only on Cloudflare, so for local work, declare an image of your own (FROM node:22-trixie, say). - Cloudflare keeps a prepared copy of each image beside it in the registry, which deleting the image doesn’t remove.
- Durable Objects: the objects containers belong to.
- Plan and ship: the rollout an image now rides along with.