Skip to content

Execute commands

Use exec() to start another process inside a running Container. The examples call this.ctx.container.exec() inside a class extending Container from @cloudflare/containers.

exec() does not start a stopped Container. In remote procedure call (RPC) methods, check this.ctx.container.running and call await this.start() when needed. You can also use the onStart() hook to run any series of commands whenever the Container starts.

Run a process after startup

The following hook runs a preparation command whenever the Container starts. You can execute any series of startup commands from this hook. output() buffers standard output and standard error as separate ArrayBuffer values.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async onStart() {
const process = await this.ctx.container.exec([
"node",
"scripts/prepare.js",
]);
const output = await process.output();
const decoder = new TextDecoder();
if (output.exitCode !== 0) {
throw new Error(
`Container preparation failed: ${decoder.decode(output.stderr)}`,
);
}
console.log(decoder.decode(output.stdout));
}
}

In an RPC method, ensure the Container is running before calling exec(). Standard output uses a readable stream by default.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async readVersion() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(["node", "--version"]);
const stdout = process.stdout
? await new Response(process.stdout).text()
: "";
const exitCode = await process.exitCode;
return { pid: process.pid, stdout, exitCode };
}
}

The returned pid identifies the new process. The exitCode promise resolves when that process exits.

Pass arguments directly

The exec() operation starts the executable directly with the provided argument array. It does not start a shell first.

Each array item becomes one argument. Shell features such as pipes, redirects, globbing, and variable expansion do not run implicitly.

Invoke a shell when your command needs those features. Use ["bash", "-lc", "<COMMAND>"] if Bash exists in your image. Use ["sh", "-c", "<COMMAND>"] if the image only provides a Portable Operating System Interface (POSIX) shell. Pass untrusted values as separate arguments instead of interpolating them into a shell command string.

Send standard input

Pass a ReadableStream to send existing data. Setting stdout to "ignore" discards standard output.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async importData(data) {
if (!this.ctx.container.running) {
await this.start();
}
const stdin = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(data));
controller.close();
},
});
const process = await this.ctx.container.exec(["cat"], {
stdin,
stdout: "ignore",
});
const output = await process.output();
return {
stdoutBytes: output.stdout.byteLength,
exitCode: output.exitCode,
};
}
}

Ignored standard output produces an empty buffer from output(). Set stdin to "pipe" to write data over time.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async concatenateInput() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(["cat"], {
stdin: "pipe",
});
const writer = process.stdin?.getWriter();
if (!writer) {
throw new Error("Standard input is unavailable");
}
const encoder = new TextEncoder();
await writer.write(encoder.encode("first\n"));
await writer.write(encoder.encode("second\n"));
await writer.close();
const output = await process.output();
return new TextDecoder().decode(output.stdout);
}
}

Close the writer to send end-of-file (EOF). If you omit stdin, exec() closes standard input and sends EOF immediately.

Pass an RPC stream to standard input

RPC methods can accept byte-oriented ReadableStream values whose underlying source uses type: "bytes". A Request body meets this requirement. You can pass the received stream directly to exec() without buffering the entire stream in the Durable Object. For more information, refer to Streams over RPC.

JavaScript
import { Container, getContainer } from "@cloudflare/containers";
export class MyContainer extends Container {
async writeFile(input) {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(["tee", "/tmp/upload.bin"], {
stdin: input,
stdout: "ignore",
});
return process.exitCode;
}
}
export default {
async fetch(request, env) {
if (!request.body) {
return new Response("Request body required", { status: 400 });
}
const container = getContainer(env.MY_CONTAINER, "upload-worker");
const exitCode = await container.writeFile(request.body);
return Response.json({ exitCode });
},
};

RPC transfers ownership of the stream to the Durable Object. The calling Worker cannot read it after passing it to writeFile().

The following cat process exits because standard input is omitted:

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async verifyEndOfFile() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(["cat"]);
const output = await process.output();
return {
stdoutBytes: output.stdout.byteLength,
exitCode: output.exitCode,
};
}
}

Set the process context

Use cwd, env, and user to set the process context. The process inherits the Container environment set by envVars. Per-execution env values add variables or override matching keys.

This example uses sh because it needs expansion and redirection. It also captures standard output and standard error separately.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
envVars = {
BASE_VALUE: "inherited",
MODE: "default",
};
async inspectWorkspace() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(
[
"sh",
"-c",
'printf "%s:%s:%s:%s" "$PWD" "$BASE_VALUE" "$MODE" "$EXTRA_VALUE"; printf "diagnostic" >&2',
],
{
cwd: "/workspace",
env: {
MODE: "inspection",
EXTRA_VALUE: "added",
},
},
);
const output = await process.output();
const decoder = new TextDecoder();
return {
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
};
}
}

The user option sets the user name or numeric user ID (UID) for the process. The Container runtime resolves user names from the container image.

Combine standard error

Set stderr to "combined" to merge standard error into standard output. Combined output requires stdout: "pipe".

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async readCombinedOutput() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(
[
"bash",
"-lc",
'printf "standard output\n"; printf "standard error\n" >&2',
],
{
stdout: "pipe",
stderr: "combined",
},
);
const output = await process.output();
return new TextDecoder().decode(output.stdout);
}
}

The merged stream does not guarantee ordering between source streams. In this mode, process.stderr is null, and output.stderr is an empty ArrayBuffer. This example assumes Bash exists in the image.

Handle nonzero exits

A nonzero exit code resolves exitCode normally. It does not reject the promise.

This example preserves standard error while ignoring standard output:

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async runCheck() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(
[
"sh",
"-c",
'printf "not captured"; printf "check failed\n" >&2; exit 7',
],
{ stdout: "ignore" },
);
const output = await process.output();
return {
exitCode: output.exitCode,
stdoutBytes: output.stdout.byteLength,
stderr: new TextDecoder().decode(output.stderr),
};
}
}

The result contains exit code 7 and the standard error text. Its ignored standard output buffer has zero bytes.

Stream large output

output() buffers both streams in memory. For large output, drain stdout and stderr concurrently instead.

JavaScript
import { Container } from "@cloudflare/containers";
async function countBytes(stream) {
if (!stream) {
return 0;
}
let bytes = 0;
for await (const chunk of stream) {
bytes += chunk.byteLength;
}
return bytes;
}
export class MyContainer extends Container {
async generateLargeOutput() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec([
"sh",
"-c",
'i=0; while [ "$i" -lt 100000 ]; do printf "output %s\n" "$i"; printf "error %s\n" "$i" >&2; i=$((i + 1)); done',
]);
const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
countBytes(process.stdout),
countBytes(process.stderr),
process.exitCode,
]);
return { stdoutBytes, stderrBytes, exitCode };
}
}

Streaming and output() are alternative consumption methods. output() throws a TypeError if either stream has started being consumed. A second call to output() also throws a TypeError.

Return standard output over RPC

Return a ReadableStream from an RPC method to stream output to the calling Worker. Combining standard error provides one stream for both output channels.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async streamCommandOutput() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(
["sh", "-c", 'printf "starting\n"; run-report'],
{ stderr: "combined" },
);
return process.stdout;
}
}

RPC transfers ownership of the stream to the caller and preserves flow control. The caller must consume or cancel the stream. If the caller stops reading, backpressure can pause a process that continues writing.

This method transfers output, not the ExecProcess handle. Define a separate application protocol when the caller needs completion metadata or process control.

Stop a process

exec() has no built-in timeout. You can request termination after a delay with kill() and then await exitCode.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async runWithTimeout() {
if (!this.ctx.container.running) {
await this.start();
}
const process = await this.ctx.container.exec(["sleep", "120"]);
const timer = setTimeout(() => process.kill(), 30_000);
try {
return await process.exitCode;
} finally {
clearTimeout(timer);
}
}
}

Calling kill() without an argument queues a SIGTERM, signal 15. You can pass another signal when the process requires it. A process can handle or ignore a signal, so this is not a hard execution deadline. Observe completion through exitCode, and do not infer a specific exit code from a signal.

Coordinate operations

Place exec() calls in the Durable Object that controls the Container. The Durable Object can coordinate process state and Container lifecycle.

One application RPC method can perform multiple exec() operations. Each command remains a separate exec operation, but the caller makes one Durable Object RPC call. This reduces caller-to-Durable Object round trips while keeping lifecycle decisions together.

JavaScript
import { Container } from "@cloudflare/containers";
export class MyContainer extends Container {
async runDiagnostics() {
if (!this.ctx.container.running) {
await this.start();
}
const commands = [
["uname", "-a"],
["node", "--version"],
];
const decoder = new TextDecoder();
const results = [];
for (const command of commands) {
const process = await this.ctx.container.exec(command);
const output = await process.output();
results.push({
command,
exitCode: output.exitCode,
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
});
}
return results;
}
}

For all fields and return types, refer to the exec() API contract.