A Second Look at Job-Shaped Work

Moving an FFmpeg pipeline off a general-purpose server often trades one operational burden for another. In a previous post, I described offloading that work to Cloudflare Containers and the coordination layer required to manage container lifecycles: heartbeat pings from inside the process while work was running, an idle-check endpoint, and shutdown signals to tell Cloudflare when the container could be stopped. That plumbing existed purely to manage state that was incidental to the actual task.

Cloudflare Sandboxes take a different approach. Call sandbox.exec(), wait for the command to finish, and the sandbox is done. No heartbeats, no shutdown signals, no idle checks.

Two Attempts at the Migration

The first spike—PR #726—treated the sandbox as a drop-in replacement for the container service. It created a dedicated call-kent-audio-sandbox service with its own Wrangler config, deploy workflow, and HTTP endpoint. The queue worker would POST a job to that endpoint; the service would start a Node process inside the sandbox, wait for a port, and proxy the request through. The heartbeat logic was gone, but the overall architecture was still a long-lived service sitting between the queue worker and the actual work.

The merged version, PR #729, made the sandbox an implementation detail of the existing worker rather than a new service around it. The queue worker became the orchestrator: it receives the message, sends a started callback, creates short-lived presigned R2 URLs for inputs and outputs, runs a single exec() call in a fresh sandbox, and sends a completed or failed callback. The sandbox runs one shell script, exits, and is destroyed in a finally block.

PR #726 (abandoned)PR #729 (merged)
Sandbox invocationWorker POSTs to sandbox service endpointWorker calls sandbox.exec() directly
Callback ownershipSandbox service sends callbacksWorker sends callbacks
R2 credentialsPassed into the sandboxKept in the worker; sandbox gets signed URLs only
Sandbox lifecycleLong-lived service process, port-ready checkOne-shot exec, destroy() in finally
Deploy surfaceSeparate service + separate workflowEmbedded in worker package

The sandbox image itself stays minimal:

FROM docker.io/cloudflare/sandbox:0.7.16

RUN apt-get update \
  && apt-get install -y --no-install-recommends ffmpeg \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /opt/call-kent-audio

COPY assets ./assets
COPY sandbox/call-kent-audio-cli.sh /usr/local/bin/call-kent-audio-cli

RUN chmod +x /usr/local/bin/call-kent-audio-cli

The shell script downloads input audio from presigned URLs, runs the FFmpeg stitching pipeline, uploads the three output files to presigned upload URLs, prints JSON with file sizes to stdout, and exits. Nothing inside the sandbox needs credentials or system-level knowledge.

const completed = await runCallKentAudioSandboxJob({
	binding: env.Sandbox,
	sandboxId: createSandboxId(parsed.draftId),
	request: {
		draftId: parsed.draftId,
		attempt,
		callAudioUrl: signedUrls.callAudioUrl,
		responseAudioUrl: signedUrls.responseAudioUrl,
		episodeUploadUrl: signedUrls.episodeUploadUrl,
		callerSegmentUploadUrl: signedUrls.callerSegmentUploadUrl,
		responseSegmentUploadUrl: signedUrls.responseSegmentUploadUrl,
	},
})

The runCallKentAudioSandboxJob function comes down to start, run, destroy:

const sandbox = getSandbox(binding, sandboxId)
try {
	const result = await sandbox.exec('/usr/local/bin/call-kent-audio-cli', {
		env: createSandboxCommandEnvironment(request),
		timeout: sandboxExecTimeoutMs,
	})
	return getSandboxOutput(result.stdout)
} finally {
	await sandbox.destroy()
}

Why the Fast Iteration Worked

At the time of the rewrite, the container version had exactly one real production run. That isn't strong data for performance claims. The justification wasn't benchmarks—it was fewer moving parts. The deleted control plane (heartbeats, idle checks, shutdown signals, a separate service with its own deploy pipeline) was complexity layered on top of a problem that already had a simpler solution.

The container migration still solved the immediate production problem. Running on it for even a day made it obvious that the lifecycle ceremony was the part that didn't need to exist. Understanding the sandbox API well enough to see that only came after trying the first version.

The full arc—container implementation, sandbox spike, redesign, comparison, and validation—took under an hour of direct work. The agent handled the exploration cost, which is usually what makes architectural iteration slow. You typically have to build something before you can form an informed opinion on whether it's the right shape. When that cost is near zero, you can try both directions and keep the one you hate the least. The PR history contains a fully abandoned direction that informed the final design.

What Testing Didn't Catch

Two issues only surfaced when the real system ran.

Sandbox ID length. The original worker generated sandbox IDs like this:

const sandboxId = `call-kent-audio-${draftId}-${crypto.randomUUID()}`

A 36-character UUID combined with a prefix produces roughly 89 characters. Cloudflare Sandbox IDs must be 1–63 characters, and the first production run failed immediately with Sandbox ID must be 1-63 characters long. The fix stripped dashes from both the draft ID and the random suffix, took the first 12 characters of each, and combined them:

function createSandboxId(draftId: string) {
	const compactDraftId = draftId.replaceAll('-', '').slice(0, 12)
	const randomSuffix = crypto.randomUUID().replaceAll('-', '').slice(0, 12)
	return `call-kent-${compactDraftId}-${randomSuffix}`
}

With call-kent- at 10 characters and two 12-character segments separated by a single hyphen, the total is 35—traceable to the draft, unique enough, and safely under the limit.

Wrong base image. During PR review, an automated bot suggested adding a non-root user to the Dockerfile. The implementing agent also added a minimal busybox httpd server as the entrypoint, likely following container conventions. But Cloudflare Sandboxes aren't containers in that sense: the @cloudflare/sandbox SDK expects a sandbox runtime baked into the base image. Basing the image on plain Debian with a custom CMD caused the SDK's exec session setup to return 501 errors because the runtime wasn't present.

Local testing didn't surface this because the mock path never uses a real sandbox image. Debugging the live failure was delegated to an agent that connected to production with real env vars, enqueued throwaway jobs with fake draft IDs, and traced the failure to the image setup. The fixed Dockerfile is six lines:

FROM docker.io/cloudflare/sandbox:0.7.16

RUN apt-get update \
  && apt-get install -y --no-install-recommends ffmpeg \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /opt/call-kent-audio
COPY assets ./assets
COPY sandbox/call-kent-audio-cli.sh /usr/local/bin/call-kent-audio-cli
RUN chmod +x /usr/local/bin/call-kent-audio-cli

Context and Takeaways

All of this happened the same day as a separate migration to npm workspaces and Nx, which moved everything under services/* and had its own production incident with hardcoded content paths and a broken Docker stage. Structural refactors break assumptions you didn't know you had; an agent's confidence isn't a substitute for verification.

The general lesson: new infrastructure primitives only help if you let them reshape what you're building. Cloudflare Sandboxes removed an entire lifecycle control plane that the container approach required. This isn't a claim that sandboxes are faster or cheaper—there's not enough data for that after two days. The win is structural. The right model for a one-shot job is a one-shot execution model, and the sandbox API makes that the default path rather than something you build around.