Repository: cloudflare/workers-sdk (2025-07-21T17:11:43Z)

Issue:
Containers do not work with multiworker runtime
### What versions & operating system are you using?

Wrangler 4.24.3

### Please provide a link to a minimal reproduction

_No response_

### Describe the Bug

### Description

Running **multi-worker** development (`wrangler dev -c … -c …`) with at least one Worker that defines the `[[containers]]` section never triggers a Docker build, so container instances cannot start locally.

### Expected behaviour

Wrangler should behave like the single-Worker flow: detect container definitions, build/pull the images via Docker, then launch container instances on demand.

### Actual behaviour

No image build happens, so container-backed functionality is unavailable.

### Root-cause analysis

* `LocalRuntimeController.#onBundleComplete()` correctly builds/pulls images by calling `getContainerOptions()` and `prepareContainerImagesForDev()` ([GitHub](https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts))
* `MultiworkerRuntimeController` overrides `#onBundleComplete()` but **omits** that block entirely, so the container code path is never executed ([GitHub](https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts))

As a result, container images are neither prepared nor cleaned up in multi-worker sessions.

### Why this is unexpected

* The Containers docs say that `wrangler dev` “will build or download” the image automatically ([Cloudflare Docs](https://developers.cloudflare.com/containers/local-dev/))
* The multi-worker docs encourage running several configs in a *single* `wrangler dev` invocation, with no mention of containers being unsupported ([Cloudflare Docs](https://developers.cloudflare.com/workers/development-testing/multi-workers/))

### Possible fixes

1. Move the container-preparation logic into a shared helper and call it from both runtime controllers.
2. Or have `MultiworkerRuntimeController` invoke the parent implementation (or factor common code into `BaseController`) before merging Miniflare options.
3. If container support in multi-worker mode is out of scope for now, surface an explicit error such as
   `error: multi-worker dev does not currently support containers`.


Thanks for taking a look!

### Please provide any relevant error logs

_No response_


Discussion:

Patch:
diff --git a/.changeset/late-taxis-switch.md b/.changeset/late-taxis-switch.md
new file mode 100644
index 000000000000..d6ffb3a50b1b
--- /dev/null
+++ b/.changeset/late-taxis-switch.md
@@ -0,0 +1,7 @@
+---
+"wrangler": patch
+---
+
+add support for containers in wrangler multiworker dev
+
+currently when running `wrangler dev` with different workers (meaning that the `-c|--config` flag is used multiple times) containers are not being included, meaning that trying to interact with them at runtime would not work and cause errors instead. The changes here address the above making wrangler correctly detect and wire up the containers.
diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts
index cd1d3d7e69e0..fdd9592020eb 100644
--- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts
+++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts
@@ -173,9 +173,9 @@ export class LocalRuntimeController extends RuntimeController {
 
 	// Set of container images that have been seen in the current dev session.
 	// This is used to clean up containers at the end of the dev session.
-	#containerImageTagsSeen: Set<string> = new Set();
+	containerImageTagsSeen: Set<string> = new Set();
 	// Stored here, so it can be used in `cleanupContainers()`
-	#dockerPath: string | undefined;
+	dockerPath: string | undefined;
 	// If this doesn't match what is in config, trigger a rebuild.
 	// Used for the rebuild hotkey
 	#currentContainerBuildId: string | undefined;
@@ -221,10 +221,10 @@ export class LocalRuntimeController extends RuntimeController {
 
 			// Assemble container options and build if necessary
 			const containerOptions = await getContainerOptions(data.config);
-			this.#dockerPath = data.config.dev?.dockerPath ?? getDockerPath();
+			this.dockerPath = data.config.dev?.dockerPath ?? getDockerPath();
 			// keep track of them so we can clean up later
 			for (const container of containerOptions ?? []) {
-				this.#containerImageTagsSeen.add(container.imageTag);
+				this.containerImageTagsSeen.add(container.imageTag);
 			}
 			if (
 				containerOptions &&
@@ -232,7 +232,7 @@ export class LocalRuntimeController extends RuntimeController {
 			) {
 				logger.log(chalk.dim("⎔ Preparing container image(s)..."));
 				await prepareContainerImagesForDev({
-					dockerPath: this.#dockerPath,
+					dockerPath: this.dockerPath,
 					configPath: data.config.config,
 					containerOptions: containerOptions,
 					onContainerImagePreparationStart: (buildStartEvent) => {
@@ -373,10 +373,10 @@ export class LocalRuntimeController extends RuntimeController {
 
 	cleanupContainers = async () => {
 		assert(
-			this.#dockerPath,
+			this.dockerPath,
 			"Docker path should have been set if containers are enabled"
 		);
-		await cleanupContainers(this.#dockerPath, this.#containerImageTagsSeen);
+		await cleanupContainers(this.dockerPath, this.containerImageTagsSeen);
 	};
 
 	#teardown = async (): Promise<void> => {
@@ -389,7 +389,7 @@ export class LocalRuntimeController extends RuntimeController {
 		await this.#mf?.dispose();
 		this.#mf = undefined;
 
-		if (this.#containerImageTagsSeen.size > 0) {
+		if (this.containerImageTagsSeen.size > 0) {
 			try {
 				await this.cleanupContainers();
 			} catch {
diff --git a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts
index dc3a3ca77ab9..baa523c3d5a2 100644
--- a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts
+++ b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts
@@ -1,12 +1,15 @@
 import assert from "node:assert";
 import { randomUUID } from "node:crypto";
+import { prepareContainerImagesForDev } from "@cloudflare/containers-shared";
 import chalk from "chalk";
 import { Miniflare, Mutex } from "miniflare";
 import * as MF from "../../dev/miniflare";
+import { getDockerPath } from "../../environment-variables/misc-variables";
 import { logger } from "../../logger";
 import { castErrorCause } from "./events";
 import {
 	convertToConfigBundle,
+	getContainerOptions,
 	LocalRuntimeController,
 } from "./LocalRuntimeController";
 import { convertCfWorkerInitBindingsToBindings } from "./utils";
@@ -73,6 +76,10 @@ export class MultiworkerRuntimeController extends LocalRuntimeController {
 		} | null
 	>();
 
+	// If this doesn't match what is in config, trigger a rebuild.
+	// Used for the rebuild hotkey
+	#currentContainerBuildId: string | undefined;
+
 	#canStartMiniflare() {
 		return (
 			[...this.#options.values()].some((o) => o.primary) &&
@@ -130,6 +137,41 @@ export class MultiworkerRuntimeController extends LocalRuntimeController {
 				);
 			}
 
+			// Assemble container options and build if necessary
+			const containerOptions = await getContainerOptions(data.config);
+			this.dockerPath = data.config.dev?.dockerPath ?? getDockerPath();
+			// keep track of them so we can clean up later
+			for (const container of containerOptions ?? []) {
+				this.containerImageTagsSeen.add(container.imageTag);
+			}
+			if (
+				containerOptions &&
+				this.#currentContainerBuildId !== data.config.dev.containerBuildId
+			) {
+				logger.log(chalk.dim("⎔ Preparing container image(s)..."));
+				await prepareContainerImagesForDev({
+					dockerPath: this.dockerPath,
+					configPath: data.config.config,
+					containerOptions,
+					onContainerImagePreparationStart: (buildStartEvent) => {
+						this.containerBeingBuilt = {
+							...buildStartEvent,
+							abortRequested: false,
+						};
+					},
+					onContainerImagePreparationEnd: () => {
+						this.containerBeingBuilt = undefined;
+					},
+				});
+				if (this.containerBeingBuilt) {
+					this.containerBeingBuilt.abortRequested = false;
+				}
+				this.#currentContainerBuildId = data.config.dev.containerBuildId;
+				// Miniflare will have logged 'Ready on...' before the containers are built, but that is actually the proxy server :/
+				// The actual user worker's miniflare instance is blocked until the containers are built
+				logger.log(chalk.dim("⎔ Container image(s) ready"));
+			}
+
 			const { options } = await MF.buildMiniflareOptions(
 				this.#log,
 				await convertToConfigBundle(data),
@@ -249,6 +291,16 @@ export class MultiworkerRuntimeController extends LocalRuntimeController {
 		await this.#mf?.dispose();
 		this.#mf = undefined;
 
+		if (this.containerImageTagsSeen.size > 0) {
+			try {
+				await this.cleanupContainers();
+			} catch {
+				logger.warn(
+					`Failed to clean up containers. You may have to stop and remove them up manually.`
+				);
+			}
+		}
+
 		if (this.#remoteProxySessionsData.size > 0) {
 			logger.log(chalk.dim("⎔ Shutting down remote connections..."));
 		}
diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts
index b77cb67976b5..dea2c21f88b8 100644
--- a/packages/wrangler/src/dev.ts
+++ b/packages/wrangler/src/dev.ts
@@ -624,7 +624,7 @@ export async function startDev(args: StartDevOptions) {
 				if (hotkeysDisplayed) {
 					assert(devEnv !== undefined);
 					unregisterHotKeys = registerDevHotKeys(
-						Array.isArray(devEnv) ? devEnv[0] : devEnv,
+						Array.isArray(devEnv) ? devEnv : [devEnv],
 						args
 					);
 				}
@@ -681,7 +681,7 @@ export async function startDev(args: StartDevOptions) {
 				))
 			);
 			if (isInteractive() && args.showInteractiveDevSession !== false) {
-				unregisterHotKeys = registerDevHotKeys(primaryDevEnv, args);
+				unregisterHotKeys = registerDevHotKeys(devEnv, args);
 			}
 		} else {
 			devEnv = new DevEnv();
@@ -736,7 +736,7 @@ export async function startDev(args: StartDevOptions) {
 			await setupDevEnv(devEnv, args.config, authHook, args);
 
 			if (isInteractive() && args.showInteractiveDevSession !== false) {
-				unregisterHotKeys = registerDevHotKeys(devEnv, args);
+				unregisterHotKeys = registerDevHotKeys([devEnv], args);
 			}
 		}
 
diff --git a/packages/wrangler/src/dev/hotkeys.ts b/packages/wrangler/src/dev/hotkeys.ts
index 91ea69bf2d48..bc4e70df2a5d 100644
--- a/packages/wrangler/src/dev/hotkeys.ts
+++ b/packages/wrangler/src/dev/hotkeys.ts
@@ -8,15 +8,16 @@ import { openInspector } from "./inspect";
 import type { DevEnv } from "../api";
 
 export default function registerDevHotKeys(
-	devEnv: DevEnv,
+	devEnvs: DevEnv[],
 	args: { forceLocal?: boolean }
 ) {
+	const primaryDevEnv = devEnvs[0];
 	const unregisterHotKeys = registerHotKeys([
 		{
 			keys: ["b"],
 			label: "open a browser",
 			handler: async () => {
-				const { url } = await devEnv.proxy.ready.promise;
+				const { url } = await primaryDevEnv.proxy.ready.promise;
 				await openInBrowser(url.href);
 			},
 		},
@@ -26,7 +27,7 @@ export default function registerDevHotKeys(
 			// Don't display this hotkey if we're in a VSCode debug session
 			disabled: !!process.env.VSCODE_INSPECTOR_OPTIONS,
 			handler: async () => {
-				const { inspectorUrl } = await devEnv.proxy.ready.promise;
+				const { inspectorUrl } = await primaryDevEnv.proxy.ready.promise;
 
 				if (!inspectorUrl) {
 					logger.warn("DevTools is not available while in a debug terminal");
@@ -34,7 +35,7 @@ export default function registerDevHotKeys(
 					// TODO: refactor this function to accept a whole URL (not just .port and assuming .hostname)
 					await openInspector(
 						parseInt(inspectorUrl.port),
-						devEnv.config.latestConfig?.name
+						primaryDevEnv.config.latestConfig?.name
 					);
 				}
 			},
@@ -43,49 +44,53 @@ export default function registerDevHotKeys(
 			keys: ["r"],
 			label: "rebuild container(s)",
 			disabled: () => {
-				return (
-					!devEnv.config.latestConfig?.dev?.enableContainers ||
-					!devEnv.config.latestConfig?.containers?.length
+				return devEnvs.every(
+					(devEnv) =>
+						!devEnv.config.latestConfig?.dev?.enableContainers ||
+						!devEnv.config.latestConfig?.containers?.length
 				);
 			},
 			handler: debounce(async () => {
-				devEnv.runtimes.forEach((runtime) => {
-					if (runtime instanceof LocalRuntimeController) {
-						if (runtime.containerBeingBuilt) {
-							// Let's abort the image built so that we
-							// can restart the build process
-							runtime.containerBeingBuilt.abort();
-							runtime.containerBeingBuilt.abortRequested = true;
-						}
-					}
-				});
-				const newContainerBuildId = randomUUID().slice(0, 8);
-				// cleanup any existing containers
-				await Promise.all(
-					devEnv.runtimes.map(async (runtime) => {
+				for (const devEnv of devEnvs) {
+					devEnv.runtimes.forEach((runtime) => {
 						if (runtime instanceof LocalRuntimeController) {
-							await runtime.cleanupContainers();
+							if (runtime.containerBeingBuilt) {
+								// Let's abort the image built so that we
+								// can restart the build process
+								runtime.containerBeingBuilt.abort();
+								runtime.containerBeingBuilt.abortRequested = true;
+							}
 						}
-					})
-				);
+					});
+					// cleanup any existing containers
+					await Promise.all(
+						devEnv.runtimes.map(async (runtime) => {
+							if (runtime instanceof LocalRuntimeController) {
+								await runtime.cleanupContainers();
+							}
+						})
+					);
 
-				// updating the build ID will trigger a rebuild of the containers
-				await devEnv.config.patch({
-					dev: {
-						...devEnv.config.latestConfig?.dev,
-						containerBuildId: newContainerBuildId,
-					},
-				});
+					const newContainerBuildId = randomUUID().slice(0, 8);
+
+					// updating the build ID will trigger a rebuild of the containers
+					await devEnv.config.patch({
+						dev: {
+							...devEnv.config.latestConfig?.dev,
+							containerBuildId: newContainerBuildId,
+						},
+					});
+				}
 			}, 250),
 		},
 		{
 			keys: ["l"],
 			disabled: () => args.forceLocal ?? false,
 			handler: async () => {
-				await devEnv.config.patch({
+				await primaryDevEnv.config.patch({
 					dev: {
-						...devEnv.config.latestConfig?.dev,
-						remote: !devEnv.config.latestConfig?.dev?.remote,
+						...primaryDevEnv.config.latestConfig?.dev,
+						remote: !primaryDevEnv.config.latestConfig?.dev?.remote,
 					},
 				});
 			},
@@ -94,7 +99,7 @@ export default function registerDevHotKeys(
 			keys: ["c"],
 			label: "clear console",
 			handler: async () => {
-				const someContainerIsBeingBuilt = devEnv.runtimes.some(
+				const someContainerIsBeingBuilt = primaryDevEnv.runtimes.some(
 					(runtime) =>
 						runtime instanceof LocalRuntimeController &&
 						runtime.containerBeingBuilt
@@ -111,7 +116,7 @@ export default function registerDevHotKeys(
 			keys: ["x", "q", "ctrl+c"],
 			label: "to exit",
 			handler: async () => {
-				devEnv.runtimes.forEach((runtime) => {
+				primaryDevEnv.runtimes.forEach((runtime) => {
 					if (runtime instanceof LocalRuntimeController) {
 						if (runtime.containerBeingBuilt) {
 							// Let's abort the image built so that we
@@ -121,7 +126,7 @@ export default function registerDevHotKeys(
 						}
 					}
 				});
-				await devEnv.teardown();
+				await primaryDevEnv.teardown();
 			},
 		},
 	]);


Test patch:
diff --git a/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts b/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts
index c2134baa0b18..262872a0fc94 100644
--- a/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts
+++ b/fixtures/interactive-dev-tests/multi-containers-app/src/index.ts
@@ -16,7 +16,7 @@ class FixtureTestContainerBase extends DurableObject<Env> {
 			});
 			// On the first request we simply start the container and return,
 			// on the following requests the container can actually be accessed.
-			// Note that we do this this way becase container.start is not awaitable
+			// Note that we do this this way because container.start is not awaitable
 			// meaning that we can't simply wait here for the container to be ready
 			return new Response("Container started");
 		}
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/tsconfig.json b/fixtures/interactive-dev-tests/multi-workers-containers-app/tsconfig.json
new file mode 100644
index 000000000000..cee17af62aba
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/tsconfig.json
@@ -0,0 +1,13 @@
+{
+	"compilerOptions": {
+		"target": "ES2020",
+		"module": "ESNext",
+		"lib": ["ES2020"],
+		"types": ["@cloudflare/workers-types"],
+		"moduleResolution": "node",
+		"noEmit": true,
+		"skipLibCheck": true
+	},
+	"include": ["**/*.ts"],
+	"exclude": ["tests"]
+}
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/Dockerfile b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/Dockerfile
new file mode 100644
index 000000000000..80908ad47aa7
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:22-alpine
+
+WORKDIR /usr/src/app
+RUN echo '{"name": "simple-node-app", "version": "1.0.0"}' > package.json
+RUN npm install
+
+RUN echo 'const { createServer } = require("http");\
+\
+const server = createServer(function (req, res) {\
+	res.writeHead(200, { "Content-Type": "text/plain" });\
+	res.write("Hello from Container A");\
+	res.end();\
+});\
+\
+server.listen(8080);\
+' > app.js
+
+EXPOSE 8080
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/index.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/index.ts
new file mode 100644
index 000000000000..cf8177b53a9d
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/index.ts
@@ -0,0 +1,38 @@
+import { DurableObject } from "cloudflare:workers";
+
+export class FixtureTestContainerA extends DurableObject<Env> {
+	container: globalThis.Container;
+
+	constructor(ctx: DurableObjectState, env: Env) {
+		super(ctx, env);
+		this.container = ctx.container;
+	}
+
+	async fetch(req: Request) {
+		if (!this.container.running) {
+			this.container.start({
+				entrypoint: ["node", "app.js"],
+				enableInternet: false,
+			});
+			// On the first request we simply start the container and return,
+			// on the following requests the container can actually be accessed.
+			// Note that we do this this way because container.start is not awaitable
+			// meaning that we can't simply wait here for the container to be ready
+			return new Response("Container started");
+		}
+		return this.container
+			.getTcpPort(8080)
+			.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
+	}
+}
+
+export default {
+	async fetch(request, env): Promise<Response> {
+		const id = env.CONTAINER_A.idFromName("container");
+		const stub = env.CONTAINER_A.get(id);
+		return Response.json({
+			containerAText: await (await stub.fetch(request)).text(),
+			containerBText: await (await env.WORKER_B.fetch(request)).text(),
+		});
+	},
+} satisfies ExportedHandler<Env>;
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/worker-configuration.d.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/worker-configuration.d.ts
new file mode 100644
index 000000000000..34fc8aafea37
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/worker-configuration.d.ts
@@ -0,0 +1,7 @@
+declare namespace Cloudflare {
+	interface Env {
+		CONTAINER_A: DurableObjectNamespace<import(".").FixtureTestContainerA>;
+		WORKER_B: Fetcher;
+	}
+}
+interface Env extends Cloudflare.Env {}
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/wrangler.jsonc b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/wrangler.jsonc
new file mode 100644
index 000000000000..0ea4daa0d96f
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerA/wrangler.jsonc
@@ -0,0 +1,28 @@
+{
+	"name": "worker-a",
+	"main": "index.ts",
+	"compatibility_date": "2025-04-03",
+	"services": [{ "binding": "WORKER_B", "service": "worker-b" }],
+	"containers": [
+		{
+			"image": "./Dockerfile",
+			"class_name": "FixtureTestContainerA",
+			"name": "container",
+			"max_instances": 2,
+		},
+	],
+	"durable_objects": {
+		"bindings": [
+			{
+				"class_name": "FixtureTestContainerA",
+				"name": "CONTAINER_A",
+			},
+		],
+	},
+	"migrations": [
+		{
+			"tag": "v1",
+			"new_sqlite_classes": ["FixtureTestContainerA"],
+		},
+	],
+}
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/Dockerfile b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/Dockerfile
new file mode 100644
index 000000000000..3e0644817dbf
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:22-alpine
+
+WORKDIR /usr/src/app
+RUN echo '{"name": "simple-node-app", "version": "1.0.0"}' > package.json
+RUN npm install
+
+RUN echo 'const { createServer } = require("http");\
+\
+const server = createServer(function (req, res) {\
+	res.writeHead(200, { "Content-Type": "text/plain" });\
+	res.write("Hello from Container B");\
+	res.end();\
+});\
+\
+server.listen(8080);\
+' > app.js
+
+EXPOSE 8080
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/index.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/index.ts
new file mode 100644
index 000000000000..9e300cde5156
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/index.ts
@@ -0,0 +1,35 @@
+import { DurableObject } from "cloudflare:workers";
+
+export class FixtureTestContainerB extends DurableObject<Env> {
+	container: globalThis.Container;
+
+	constructor(ctx: DurableObjectState, env: Env) {
+		super(ctx, env);
+		this.container = ctx.container;
+	}
+
+	async fetch(req: Request) {
+		if (!this.container.running) {
+			this.container.start({
+				entrypoint: ["node", "app.js"],
+				enableInternet: false,
+			});
+			// On the first request we simply start the container and return,
+			// on the following requests the container can actually be accessed.
+			// Note that we do this this way because container.start is not awaitable
+			// meaning that we can't simply wait here for the container to be ready
+			return new Response("Container started");
+		}
+		return this.container
+			.getTcpPort(8080)
+			.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
+	}
+}
+
+export default {
+	async fetch(request, env): Promise<Response> {
+		const id = env.CONTAINER_B.idFromName("container");
+		const stub = env.CONTAINER_B.get(id);
+		return stub.fetch(request);
+	},
+} satisfies ExportedHandler<Env>;
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/worker-configuration.d.ts b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/worker-configuration.d.ts
new file mode 100644
index 000000000000..63133ed0d877
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/worker-configuration.d.ts
@@ -0,0 +1,6 @@
+declare namespace Cloudflare {
+	interface Env {
+		CONTAINER_B: DurableObjectNamespace<import(".").FixtureTestContainerB>;
+	}
+}
+interface Env extends Cloudflare.Env {}
diff --git a/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/wrangler.jsonc b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/wrangler.jsonc
new file mode 100644
index 000000000000..8af9d534bb66
--- /dev/null
+++ b/fixtures/interactive-dev-tests/multi-workers-containers-app/workerB/wrangler.jsonc
@@ -0,0 +1,27 @@
+{
+	"name": "worker-b",
+	"main": "index.ts",
+	"compatibility_date": "2025-04-03",
+	"containers": [
+		{
+			"image": "./Dockerfile",
+			"class_name": "FixtureTestContainerB",
+			"name": "container",
+			"max_instances": 2,
+		},
+	],
+	"durable_objects": {
+		"bindings": [
+			{
+				"class_name": "FixtureTestContainerB",
+				"name": "CONTAINER_B",
+			},
+		],
+	},
+	"migrations": [
+		{
+			"tag": "v1",
+			"new_classes": ["FixtureTestContainerB"],
+		},
+	],
+}
diff --git a/fixtures/interactive-dev-tests/tests/index.test.ts b/fixtures/interactive-dev-tests/tests/index.test.ts
index e4a264ca73d8..e874a901c2ec 100644
--- a/fixtures/interactive-dev-tests/tests/index.test.ts
+++ b/fixtures/interactive-dev-tests/tests/index.test.ts
@@ -729,6 +729,125 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
 	}
 );
 
+baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
+	"multi-workers containers dev",
+	{ retry: 0, timeout: 50000 },
+	() => {
+		let tmpDir: string;
+		beforeAll(async () => {
+			tmpDir = fs.mkdtempSync(
+				path.join(tmpdir(), "wrangler-multi-workers-containers-")
+			);
+			fs.cpSync(
+				path.resolve(__dirname, "../", "multi-workers-containers-app"),
+				path.join(tmpDir),
+				{
+					recursive: true,
+				}
+			);
+
+			const ids = getContainerIds();
+			if (ids.length > 0) {
+				execSync("docker rm -f " + ids.join(" "), {
+					encoding: "utf8",
+				});
+			}
+		});
+
+		afterEach(async () => {
+			const ids = getContainerIds();
+
+			if (ids.length > 0) {
+				execSync("docker rm -f " + ids.join(" "), {
+					encoding: "utf8",
+				});
+			}
+		});
+		afterAll(async () => {
+			try {
+				fs.rmSync(tmpDir, { recursive: true, force: true });
+			} catch (e) {
+				// It seems that Windows doesn't let us delete this, with errors like:
+				//
+				// Error: EBUSY: resource busy or locked, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\wrangler-modules-pKJ7OQ'
+				// ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
+				// Serialized Error: {
+				// 	"code": "EBUSY",
+				// 	"errno": -4082,
+				// 	"path": "C:\Users\RUNNER~1\AppData\Local\Temp\wrangler-modules-pKJ7OQ",
+				// 	"syscall": "rmdir",
+				// }
+				console.error(e);
+			}
+		});
+
+		it("should be interact with both workers, rebuild the containers with the hotkey and all containers should be cleaned up at the end", async () => {
+			const wrangler = await startWranglerDev([
+				"dev",
+				"-c",
+				path.join(tmpDir, "workerA/wrangler.jsonc"),
+				"-c",
+				path.join(tmpDir, "workerB/wrangler.jsonc"),
+			]);
+
+			await vi.waitFor(
+				async () => {
+					const json = await (
+						await fetch(wrangler.url, { signal: AbortSignal.timeout(5_000) })
+					).json();
+					expect(json).toEqual({
+						containerAText: "Hello from Container A",
+						containerBText: "Hello from Container B",
+					});
+				},
+				{ timeout: 10_000, interval: 1000 }
+			);
+
+			const tmpDockerfileAPath = path.join(tmpDir, "workerA/Dockerfile");
+			const dockerFileAContent = fs.readFileSync(tmpDockerfileAPath, "utf8");
+			fs.writeFileSync(
+				tmpDockerfileAPath,
+				dockerFileAContent.replace(
+					'"Hello from Container A"',
+					'"Hello World from Container A"'
+				),
+				"utf-8"
+			);
+
+			const tmpDockerfileBPath = path.join(tmpDir, "workerB/Dockerfile");
+			const dockerFileBContent = fs.readFileSync(tmpDockerfileBPath, "utf8");
+			fs.writeFileSync(
+				tmpDockerfileBPath,
+				dockerFileBContent.replace(
+					'"Hello from Container B"',
+					'"Hello from the B Container"'
+				),
+				"utf-8"
+			);
+
+			wrangler.pty.write("r");
+
+			await vi.waitFor(
+				async () => {
+					const json = await (
+						await fetch(wrangler.url, { signal: AbortSignal.timeout(5_000) })
+					).json();
+					expect(json).toEqual({
+						containerAText: "Hello World from Container A",
+						containerBText: "Hello from the B Container",
+					});
+				},
+				{ timeout: 30_000, interval: 1000 }
+			);
+
+			fs.writeFileSync(tmpDockerfileAPath, dockerFileAContent, "utf-8");
+			fs.writeFileSync(tmpDockerfileBPath, dockerFileBContent, "utf-8");
+
+			wrangler.pty.kill("SIGINT");
+		});
+	}
+);
+
 /** gets any containers that were created by running this fixture */
 const getContainerIds = () => {
 	// note the -a to include stopped containers