Skip to content

feat(cloudflare): support native Durable Object containers - #1905

Open
danieljvdm wants to merge 15 commits into
alchemy-run:mainfrom
danieljvdm:feat/cloudflare-durable-object-containers
Open

danieljvdm wants to merge 15 commits into
alchemy-run:mainfrom
danieljvdm:feat/cloudflare-durable-object-containers

Conversation

@danieljvdm

@danieljvdm danieljvdm commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Adds Cloudflare's Durable Object-managed containers (schedulingPolicy: "durable_object"): one container application exposes several named images, and each Durable Object picks the image, instance size, entrypoint and env when it starts its container. Works in alchemy deploy and in alchemy dev (local Docker).

Declaring images

Each image is a registry reference or a Docker build (up to 100 named images). Images are prepared and pushed before the Worker uploads, so a new Worker version never references an image that doesn't exist yet.

export class Sandbox extends Cloudflare.Container<Sandbox>()("Sandbox", {
  schedulingPolicy: "durable_object",
  images: {
    shell: { image: "alpine:3.21" },
    node: { dockerfile: "./sandbox/Dockerfile", context: "./sandbox" },
  },
  imagePreparationTimeout: "1 hour", // default 30 minutes; deploy reports progress while Cloudflare prepares
}) {}

Choosing an image at runtime

Containers.bind attaches the container to a Durable Object and returns an Effect client. The image names are typed from the declaration, so images.shell and images.node are string. Starting is explicit, so the container only runs when a request needs it.

export class Workspace extends Cloudflare.DurableObject<Workspace>()(
  "Workspace",
  Effect.gen(function* () {
    const sandbox = yield* Cloudflare.Containers.bind(Sandbox);

    const ensureStarted = (runtime: "shell" | "node") =>
      Effect.gen(function* () {
        if (yield* sandbox.running) return;
        const images = yield* sandbox.images;
        yield* sandbox.start({
          image: images[runtime],
          instance: runtime === "node" ? "standard-1" : "lite",
          entrypoint: ["sleep", "infinity"],
          enableInternet: false,
        });
      });

    const run = (runtime: "shell" | "node", cmd: string[]) =>
      ensureStarted(runtime).pipe(
        Effect.andThen(sandbox.exec(cmd)),
        Effect.flatMap((child) => child.output()),
        Effect.scoped,
      );

    return Effect.gen(function* () {
      return { run };
    });
  }),
) {}

start also accepts managed images (image: "cloudflare/debian-trixie") that aren't declared in images.

Exec, stdin and snapshots

exec returns a process owned by the calling scope: closing the scope kills it. stdin is a Sink, so input streams in.

const child = yield* sandbox.exec(["cat"], { stdin: "pipe" });
const [, output] = yield* Effect.all(
  [Stream.make(new TextEncoder().encode("input")).pipe(Stream.run(child.stdin!)), child.output()],
  { concurrency: "unbounded" },
);

Snapshots capture the writable filesystem and restore it into a fresh container:

const snapshot = yield* sandbox.snapshotContainer();
yield* sandbox.destroy();
yield* sandbox.start({ containerSnapshot: snapshot, entrypoint: ["sleep", "infinity"], enableInternet: false });

The client also exposes inspect, monitor, signal, getTcpPort, setInactivityTimeout and outbound HTTP interception. All runtime methods require RuntimeContext.

Async Workers

The same declaration binds on a plain Worker's env with a className. The Durable Object then uses this.ctx.container (images, start, exec, snapshotContainer) directly.

const Sandbox = Cloudflare.Container<SandboxObject>("SANDBOX", {
  className: "SandboxObject",
  schedulingPolicy: "durable_object",
  images: { shell: { image: "alpine:3.21" } },
});
const worker = yield* Cloudflare.Worker("Api", { main: "./src/worker.ts", env: { SANDBOX: Sandbox } });

Application settings

  • wranglerSsh, authorizedKeys and observability are managed on the application; removing one clears it.
  • The scheduling policy is immutable. Switching an existing application between fleet and durable_object is rejected with a clear error instead of being applied in place.

Local development

  • Named images, managed images, exec (including docker exec streams) and snapshots run against local Docker under alchemy dev.
  • Images that workerd starts without preparation are pulled on demand.
  • Every container the runtime creates is labelled and removed when the runtime stops, including containers started from snapshots or managed images.

Depends on distilled#685 (merged) and builds on #2028 and #2059.

Known limitations

  • On a live deploy, adding, removing or replacing named images can leave ctx.container.images stale even though the Worker's metadata is correct. Those live regressions are marked TODO; local coverage stays enabled.

I ran the container tests locally, both under alchemy dev (workerd + Docker) and live against Cloudflare. All passed: DurableObjectContainer 6 (2 todo), image preparation 3, Docker 27, and cloudflare-runtime Docker + NativeContainer 25.

@alchemy-version-bot

alchemy-version-bot Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

pnpm install https://pkg.alchemy.run/alchemy/pr:1905:c34ad29
@alchemy.run (6)
pnpm install https://pkg.alchemy.run/@alchemy.run/better-auth/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@alchemy.run/cloudflare-runtime/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@alchemy.run/node-utils/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@alchemy.run/floci/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@alchemy.run/pkg/pr:1905:c34ad29
@distilled.cloud (16)
pnpm install https://pkg.alchemy.run/@distilled.cloud/core/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/acme/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/aws/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/axiom/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/cloudflare/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/doppler/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/fly-io/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/github/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/hetzner/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/infisical/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/neon/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/prisma/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/planetscale/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/railway/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/stripe/pr:1905:c34ad29
pnpm install https://pkg.alchemy.run/@distilled.cloud/zerossl/pr:1905:c34ad29

Published Oct 6, 2026, 9:32 PM UTC. Expires Oct 13, 2026, 9:32 PM UTC, extended while this pull request is open.

@JRGiardiniere

JRGiardiniere commented Oct 2, 2026 •

Copy link
Copy Markdown

Three notes from checking this against two live durable_object applications that cf deploy created (details and the live JSON are in alchemy-run/distilled#685 (comment) and #1906):

  1. Response shape. The live API omits instances, max_instances and version for these applications, and returns configuration: {}. With distilled#685, lenient decoding (the default, and what Alchemy uses) succeeds. Strict decoding fails on instances, then on version. toAttributes therefore records instances / version as undefined while the Attributes type says number.
  2. SSH. The props don't expose wrangler_ssh / authorized_keys for durable_object applications, though distilled#685 models them in the SDK. cf sends them from its ssh / authorizedKeys in the create body and patches them on later deploys. ctx.container.start() takes no SSH option, so the application is the only place to set them.
  3. Policy switch. Refusing an in-place policy change (validateContainerConfiguration) matches what Cloudflare documents and what Cloudflare.Container: support the durable_object scheduling policy (named images map) #1906 asks for. 👍

I'm happy to send a follow-up PR for 1 and 2 on top of whichever implementation lands.

@Butch78

Butch78 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Closed #1904 in favour of this one. A data point from deploying the #1904 preview with a large image, in case it helps here.

Image preparation on large images. Our container image is a nix + devenv image (~1.8 GB). Under schedulingPolicy: "durable_object", Cloudflare's image preparation for it was still pending after ~17 minutes, when #1904's poll gave up. This PR polls prepareContainerImage 10 times, 5 seconds apart, then fails with "Deploy again to resume preparation", so an image like ours would need many deploys before the Worker could upload. A configurable bound (or waiting until the status leaves pending, with progress notes) would cover large images.

Order of operations. Preparing named images before the Worker uploads, as this PR does, matters. On #1904 the Worker uploaded first, so the timeout left a stage whose Durable Object code expected images.default while its application was still on the default policy, and every container start failed until we redeployed the previous version.

Containers.bind without starting is exactly what we need. Our Durable Objects start per request, from the base's snapshot when there is one, and until now we reached that through the internal ~alchemy/Container/Binding key.

Butch78 added a commit to Butch78/ficus that referenced this pull request Oct 3, 2026
Merges the Effect-native sandbox: Scorer and Worktree Durable Objects on
Durable Object-managed containers (machine.ts, containers.ts), Egress as
the Worker's default export, warmed snapshots per base. Kept from main and
dogfood: the tree tends its agents (PR #5's Api-started agents and
src/api/agents.ts are dropped), the [fetch] phase, streamed scoring
progress, SECRETSPEC_REASON, the release-assets host, judged reports'
touched paths. The Rust snapshot logic is ported to the TS tree: core's
snapshotPlans, SnapshotNews in the streamed outcome line (headers on a
plain answer), snapshot:<base> in TreeObject, and Started carries the
base's snapshot to the agent's Worktree.

Legacy removed: the botany-vocabulary layer (legacy.ts, its fixture and
test) and scores without confidence from before judges.

Does not typecheck yet: Containers.bind and schedulingPolicy come from
alchemy-run/alchemy#1905, unreleased; package.json is on alchemy
2.0.0-beta.80. How #1905 is pulled in is undecided.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BlankParticle
BlankParticle force-pushed the feat/cloudflare-durable-object-containers branch 2 times, most recently from a1967bc to 5eeae6f Compare October 6, 2026 08:41
@BlankParticle BlankParticle changed the title feat(cloudflare): support native Durable Object containers feat(cloudflare): update runtime & support native Durable Object containers Oct 6, 2026
@BlankParticle
BlankParticle force-pushed the feat/cloudflare-durable-object-containers branch from 4f6eb94 to 28850e8 Compare October 6, 2026 10:24
@BlankParticle
BlankParticle changed the base branch from main to chore/cloudflare-runtime-20261002 October 6, 2026 10:25
@BlankParticle BlankParticle changed the title feat(cloudflare): update runtime & support native Durable Object containers feat(cloudflare): support native Durable Object containers Oct 6, 2026
@BlankParticle
BlankParticle force-pushed the feat/cloudflare-durable-object-containers branch from 28850e8 to bc7acba Compare October 6, 2026 10:49
@BlankParticle
BlankParticle deleted the branch alchemy-run:main October 6, 2026 11:02
@BlankParticle BlankParticle reopened this Oct 6, 2026
@BlankParticle
BlankParticle changed the base branch from chore/cloudflare-runtime-20261002 to main October 6, 2026 11:04
@BlankParticle
BlankParticle force-pushed the feat/cloudflare-durable-object-containers branch 2 times, most recently from 11ecfda to b2bdc96 Compare October 6, 2026 19:30
danieljvdm and others added 7 commits October 7, 2026 02:22
Validate native image names and counts, preserve named-image records, and use the concrete Cloudflare Fetcher contract. Scope Docker initialization and generate local Docker tags independently of user image names.

Extend lifecycle coverage for image additions and removals, verify uploaded Worker metadata, and bound runtime requests. Update distilled to the reviewed container SDK fixes.

Validated with 50 focused Alchemy/runtime tests, local and live Cloudflare lifecycles, type checks, provider lint, JSDoc checks, documentation generation, and formatting.
…oute docker exec

Layer the native container pieces onto the shared loopback proxy: pull
images that workerd starts without preparation, and send docker exec
upgrade streams straight to Docker through the router.
Label every container created through the Docker proxy with the runtime's
id and remove them all when the Docker layer closes. Native containers can
start from snapshot or managed images the runtime never prepared, which the
per-image cleanup could not find, so each run leaked the containers and
their proxy sidecars.
…ve coverage

- Delete ContainerClient.test.ts (fake cf.Container, constant restatements,
  direct settings-helper calls); keep its instanceType type check
- Keep only the deadline and error-status cases of image preparation
- Exercise exec interrupt, streamed stdout and monitor() failures through
  the native fixture, locally and live, and assert local dev: identities
- Add live cases for SSH/authorized keys/observability and for rejecting
  scheduling-policy switches; re-enable local image replacement
- Use real Docker for pull output notes and on-demand pull retry instead of
  stubbed spawners
- Note that the docker exec router and half-close socket wrapper are only
  needed on Bun 1.3.x
…her in Containers.bind

- Pass `binding.raw` to interceptOutboundHttp, interceptAllOutboundHttp and
  interceptOutboundHttps on the Containers.bind client, matching alchemy-run#2062
- Keep alchemy-run#2062's fix in the Containers.layer handle after the refactor
- Fail Containers.layer monitor() with ContainerError instead of a defect,
  so StartContainer's catchTag("ContainerError") handles crashes
- Cover interception through Containers.bind in the native fixture locally;
  live interception closes the connection with no response (TODO)
@BlankParticle
BlankParticle force-pushed the feat/cloudflare-durable-object-containers branch from 9b3ef6b to 1b7a3c1 Compare October 6, 2026 21:09
Cloudflare routes intercepted container traffic only to a Worker
entrypoint or service binding (ctx.exports); Durable Object stubs work in
local workerd but not live. Register the Worker's default entrypoint so
the intercept assertion runs live as well.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants