⚠️ Since publishing: Cloudflare added a backup/restore API for sandboxes — setup once, snapshot to R2, restore in ~2s. Evaluate that before using a prebuilt docker image.
A while back (before the claude -p pricing change) I was using Cloudflare Sandboxes to spin up ephemeral workspaces to triage issues i’d see in our errors slack channel.
It was an API invocable LLM workspace (think a home built Devin). The idea was to just spawn off a task into a cloud workspace, come back to a finished PR or a triage report of findings etc. but that’s not the point of this blog post.
The biggest thing that was necessary to solve for it to be usable was the start up time. In the original iteration, the sandbox (with 1 or 2 vCPUs max) would have to clone N (in the dozens) repositories, run npm install in each of them before it could even start its work. This would take multiple minutes, completely negating the “time savings” of the task being offloaded somewhere when I could just as quickly paste that into conductor or some other local tool to fire off a worktree and get going.
📚 Ramp’s inspiration post:
The craft of engineering is rapidly changing. At @tryramp, we built our own background coding agent to accelerate faster.
— Zach Bruggeman (@zachbruggeman) January 12, 2026
We call it Inspect. It wrote 30% of merged frontend + backend PRs in the past week.
It’s powered by @opencode, @modal and @CloudflareDev. It runs fully in… pic.twitter.com/TW5CJr5iDB
I remember reading in Ramp’s architecture article about Inspect that they were pre-creating images with their repositories every 30 minutes so that every new sandbox was always reasonably up to date. Not that trivial to understand, but not super trivial to implement on cloudflare sandboxes (at least at the time).
TL;DR - set up your workspace (clone the repos and run npm install as an example) at image build time instead of on every run, push the image to Cloudflare’s container registry on a cron, point the Sandbox SDK at it, and have the worker check a marker file to know whether it can skip setup.
Here are the work examples with the gotchas to look out for:
Building the image
The whole thing lives in one Dockerfile. A few parts matter more than others.
# Prebuilt sandbox image — repos pre-cloned + deps pre-installed.
# Built on a schedule by GHA and pushed to Cloudflare's managed registry.
# Reduces sandbox startup from ~150s to ~5s for the default repo set.
#
# Build: docker build -f Dockerfile.prebuilt --secret id=github_token,env=GITHUB_TOKEN -t prebuilt-sandbox .
# Push: npx wrangler containers push prebuilt-sandbox
FROM docker.io/cloudflare/sandbox:0.7.4
# You HAVE to use cloudflare's starter sandbox image.
# ── System deps (same as base Dockerfile) ──────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends sudo \
&& rm -rf /var/lib/apt/lists/*
# Installing Various Dependencies, skip to below "Pre-clone repos" if you only need that.
# GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*
# AWS CLI v2
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install \
&& rm -rf awscliv2.zip aws
# Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Non-root user
RUN useradd -m -s /bin/bash claude \
&& echo "claude ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Workspace
RUN mkdir -p /workspace/logs && chown -R claude:claude /workspace
WORKDIR /workspace
# ── Git config (as claude user) ────────────────────────────────────────
USER claude
RUN git config --global init.defaultBranch main \
&& git config --global user.email "<your agent "email">@<your domain>" \
&& git config --global user.name "<Your Agent Name>"
ENV GIT_TERMINAL_PROMPT=0
ENV GCM_INTERACTIVE=never
# ── Pre-clone repos + install deps ────────────────────────────────────
# Uses Docker BuildKit secrets to avoid baking the token into image layers.
# The --mount=type=secret is ephemeral — it's available during build but
# NOT persisted in the final image.
# Put your organization and repos here - assumes a single org.
reorganize the below section if you have more than 1 org to pull repositories from
ARG GITHUB_ORG=BrianVia
ARG REPOS="repo1 repo2 repo3 repo4"
RUN --mount=type=secret,id=github_token,uid=1000 \
git config --global credential.helper store \
&& echo "https://oauth2:$(cat /run/secrets/github_token)@github.com" > /home/claude/.git-credentials \
&& cd /workspace \
&& for repo in $REPOS; do \
git clone --depth 1 "https://github.com/${GITHUB_ORG}/${repo}.git" || exit 1; \
done \
&& rm -f /home/claude/.git-credentials \
&& git config --global --unset credential.helper
# Install deps for each repo that has a package.json + lockfile.
# npmrc for internally scoped packages on github container registry if you need - this requires the token at install time too.
RUN --mount=type=secret,id=github_token,uid=1000 \
echo "registry=https://registry.npmjs.org/" > /home/claude/.npmrc \
&& echo "@<your-org>:registry=https://npm.pkg.github.com/<your-org>" >> /home/claude/.npmrc \
&& echo "//npm.pkg.github.com/:_authToken=$(cat /run/secrets/github_token)" >> /home/claude/.npmrc \
&& for repo in $REPOS; do \
echo "Installing deps: ${repo}"; \
(cd "/workspace/${repo}" && npm ci --ignore-scripts) > /tmp/npm-ci.log 2>&1 \
|| { cat /tmp/npm-ci.log; exit 1; }; \
tail -1 /tmp/npm-ci.log; \
done \
&& rm -f /home/claude/.npmrc /tmp/npm-ci.log
# Mark as prebuilt so orchestrator can skip clone+install
RUN echo '{"prebuilt":true,"repos":'$(echo $REPOS | wc -w)',"builtAt":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > /workspace/.prebuilt.json
Shipping it to Cloudflare via GitHub Actions (on a cron job)
name: Prebuild Sandbox Image and Upload to Cloudflare
on:
schedule:
# Every 3 hours
- cron: '0 */3 * * *'
workflow_dispatch:
jobs:
prebuild:
name: Build & Push Prebuilt Image
runs-on: ubuntu-latest
defaults:
run:
working-directory: worker
steps:
# Free ~25GB by removing pre-installed tools we don't need
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo rm -rf /opt/hostedtoolcache/CodeQL
docker system prune -af
df -h /
working-directory: .
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: worker/package-lock.json
- run: npm ci
# Build the prebuilt image with BuildKit secrets (token never in layers)
# Tag as claude-containers-sandbox (must match the CF container app name)
- name: Build prebuilt image
run: |
DOCKER_BUILDKIT=1 docker build \
-f Dockerfile.prebuilt \
--secret id=github_token,env=GITHUB_TOKEN \
-t claude-containers-sandbox:prebuilt \
.
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
DOCKER_BUILDKIT: 1
# Push to Cloudflare's managed registry — repo name must match container app
- name: Push to Cloudflare Registry
run: npx wrangler containers push claude-containers-sandbox:prebuilt
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Show what was pushed so we know the tag
- name: List registry images
run: npx wrangler containers images list
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Deploy the worker so new sandboxes pick up the fresh prebuilt image
- name: Deploy Worker
run: npx wrangler deploy --containers-rollout immediate
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Now, the gotcha that cost me an afternoon and quite a few tokens: the image tag has to match your Cloudflare container application name. Cloudflare specifically keys the registry path off the container app, and if they don’t match, the push will succeed but your image will silently never gets used and it doesn’t really tell you (except that you see how long startup takes). If your “successful” push doesn’t look like it’s getting picked up, check this first.
Also: --containers-rollout immediate matters. Without it, new sandboxes keep pulling the cached images until a lazy rollout eventually catches up. This ensures your new image gets used immediately.
Wiring it up in your wrangler configuration
Once your image is in Cloudflare’s registry, you just need to point to it in your wrangler.jsonc:
"containers": [
{
"class_name": "Sandbox",
"image": "registry.cloudflare.com/<cloudflare-account-hash>/claude-containers-sandbox:prebuilt",
"instance_type": "standard-2",
"max_instances": 10
}
]
That’s it. When the SDK provisions a sandbox, it’ll pull your custom workspace image, and startup times should be meaningfully improved compared to setup being done at runtime. (In my case claude-containers-sandbox was my cloudflare application name as mentioned above).
In your application you can also do the below (swap to a plain dockerfile so that you don’t have to pull a many gig file from Cloudflare container registry every time) :
export function createSandbox(env: Env, runId: string): Sandbox {
return getSandbox(env.SANDBOX, runId, { keepAlive: true });
Optional runtime checking that it worked.
Remember that marker line at the end of the dockerfile?
RUN echo '{"prebuilt":true,"repos":'$(echo $REPOS | wc -w)',"builtAt":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > /workspace/.prebuilt.json - this will check that stuff is working and tell you easily if it’s not.
const prebuiltCheck = await this.sandbox.exists('/workspace/.prebuilt.json');
if (prebuiltCheck.exists) {
// Prebuilt image - just discover what's already on disk
const lsResult = await this.sandbox.exec('ls -d /workspace/*/');
this.clonedPaths = lsResult.stdout
.trim()
.split('\n')
.map((p) => p.replace(/\/$/, ''))
.filter((p) => p && !p.includes('logs'));
} else {
// Old slow path, still here as a fallback
this.clonedPaths = await cloneRepos(this.sandbox, repos);
pendingSaves = await installDeps(this.sandbox, this.clonedPaths, /* ... */);
}
Do your images go stale then?
Yeah, it’s a snapshot and that’s part of the trade off. Weigh the below factors how you want.
- You can run your cron job however often you want OR on every commit if you’re feeling frisky enough, it just uses more GitHub actions minutes. Make the choice that’s right for your organization’s speed and cost concerns.
- Optionally at runtime you can run
git fetchin each repo and catch up to whatever changes were missed.
await sandbox.exec(
`git fetch origin && git checkout ${branch} && git pull origin ${branch} --ff-only || true`,
{ cwd: path },
);
Pulling a few hours of commits takes seconds, but maybe that’s acceptable to you 🤷🏻♂️
The result
Before: Multiple minutes of cloning repos and running install scripts.
After: ~5 seconds cold (basically all of it Cloudflare provisioning the container, which we can’t do anything about), and sub-second when the sandbox is warm.
The pattern generalizes to anything you want to do for your workspaces of course. Bake in whatever setup scripting you’d need to do anyway on a local dev box and then your agents have just as usable a workspace as you do on your fancy macbook pro.
Thanks for reading. Hope some folks find this useful!
Since I built this, Cloudflare shipped a backup and restore API for sandboxes — do your setup once, createBackup() to R2, then restoreBackup() in ~2s on every new sandbox. Evaluate that before reaching for a prebuilt image. You’ll still want a custom image for tooling (Claude Code, gh, etc), but it kills the whole GHA cron + registry push dance for the repos.
Post-publish notes (AI written)
Since I built this, Cloudflare shipped a backup and restore API for sandboxes — do your setup once, createBackup() to R2, then restoreBackup() in ~2s on every new sandbox. Evaluate that before reaching for a prebuilt image. You might still want a custom image for tooling (Claude Code, gh, etc), but it kills the whole GHA cron + registry push dance for the repos. Watch the default 3-day backup TTL though — expired backups don’t auto-delete from R2, so set lifecycle rules.
The SDK has also moved on from the 0.7.4 pinned above — it’s at 0.12.x as of writing (new rpc transport, no more 32MB write limit, Node 24 default). The npm package version has to match your base image tag, so bump both together.