Skip to content

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:

boxes.ts
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.

Anything Docker builds, for Cloudflare’s machines (linux/amd64). Ours is tiny, busybox serving one page:

site/Dockerfile
FROM busybox:1.37
COPY www /www
EXPOSE 8080
CMD ["httpd", "-f", "-p", "8080", "-h", "/www"]

A box is a Durable Object. Its contract is the usual schemas:

box.contract.ts
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:

boxes.ts
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 elsewhere

Another Worker reaches a box the way it reaches any object, by name: (yield* Box).byName('one').page({ path: '/' }).

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.

preview.ts
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:

boxes.ts
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.

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 }).

$ pnpm infra plan example/sandbox/app.ts --env c1
sandbox (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.dev

Because 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.

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.

  • Docker, to ship a Dockerfile. infra check warns 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: ensure replaces one running an older version’s image the next time it’s called, and sleepAfter lets idle ones go.
  • It costs money while it runs. Containers bill for the time they run; sleepAfter and destroy are 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 dev run 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.