Swap-in replacement for docker to run your containers on CF
Rust
0
51 commits
updated Sep 20, 2026
A drop-in Docker replacement that runs your containers on Cloudflare Containers and tunnels them back to localhost.
Point the Docker CLI at BridgeFlare instead of your Docker daemon. docker run and
docker compose up provision real containers on Cloudflare, fronted by a Worker and
reachable at localhost:<port> — secured so nobody but you can reach them.
docker rm / docker compose down tears every Cloudflare resource back down.
docker CLI ──unix socket──▶ BridgeFlare ──▶ Cloudflare Worker ──▶ Container
(DOCKER_HOST) │ ▲
localhost:8080 ◀── local proxy (injects per-container auth) ──────────┘
Status: experimental. It runs real workloads — containers,
composeprojects, databases, volumes and local builds — but Cloudflare's runtime imposes real edges. Read Limitations before depending on it.
You'll need: Rust (edition 2024) · Docker Desktop or OrbStack running
(BridgeFlare uses it to build the linux/amd64 image it pushes) · Node.js
and wrangler (npm install -g wrangler) · a Cloudflare account on the Workers Paid plan
(Containers requires it).
bridgeflare serve checks for these on startup and prints the fix for whatever is missing.
Without wrangler it falls back to npx wrangler — slower, but it works.
# 1. Install — puts `bridgeflare` on your PATH (~/.cargo/bin, which rustup set up)
git clone <this repo> && cd bridgeflare
cargo install --path . --locked
# 2. Sign in to Cloudflare
bridgeflare login
# 3. Start the daemon — in a shell with DOCKER_HOST unset,
# because it needs your real Docker to build images.
bridgeflare serve
The installed binary is self-contained — the checkout can go once it's installed.
bridgeflare: command not found? Add ~/.cargo/bin to your PATH. To upgrade, git pull
and run the install again; to remove it, cargo uninstall bridgeflare. Where sudo resets
PATH (Linux's secure_path), write sudo "$(command -v bridgeflare)" … for the few
commands that need root.
In a second terminal, point Docker at it:
export DOCKER_HOST=unix:///Users/$USER/.bridgeflare/bridgeflare.sock
export DOCKER_BUILDKIT=0 # BridgeFlare implements the classic builder — see below
docker version # Server: BridgeFlare
Run something:
docker run -d --name web -p 8080:80 traefik/whoami # ~60s first time: builds + pushes
curl localhost:8080 # 200, served from Cloudflare
docker ps
docker logs -f web # live tail (Ctrl-C to stop)
docker rm -f web # tears down every Cloudflare resource
That's the whole loop. New to it? bridgeflare welcome prints this walkthrough any time.
First run of an image takes ~60s while it is built for
linux/amd64and pushed. Subsequent runs of the same image are fast.
docker compose, with real service namesmkdir -p /tmp/bftest && cd /tmp/bftest
cat > docker-compose.yml <<'YML'
services:
web:
image: ealen/echo-server
ports: ["8094:80"]
db:
image: postgres:16
ports: ["5432:5432"]
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
YML
docker compose up -d
docker compose ps
docker compose down
From inside web, the database is postgresql://app:app@db:5432/app — the same URL your
compose file would use locally, unchanged.
Peers answer to their plain service name, exactly as under real Docker Compose. Cloudflare has no container network, so BridgeFlare bakes a small static agent into each image: it runs a loopback resolver and tunnels each peer port out to that peer's Worker.
The <PEER>_URL, <PEER>_AUTH and BF_MESH environment variables are still injected, so
config-driven apps work too.
Both run over the raw-TCP tunnel, so psql and redis-cli work against localhost:
docker run -d --name db -p 5432:5432 \
-e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app postgres:16
psql -h 127.0.0.1 -U app -d app # your own credentials, honoured
redis runs as-is. Stock postgres needs help — its entrypoint wants a /var/run it
can't write on Cloudflare's rootless runtime — so BridgeFlare generates an image that
drives initdb/postgres directly and honours POSTGRES_USER / POSTGRES_PASSWORD /
POSTGRES_DB.
mkdir -p /tmp/seed && echo "hello from the host" > /tmp/seed/hello.txt
docker run -d --name seeded -p 8080:8080 -v /tmp/seed:/data \
python:3-alpine python3 -m http.server 8080 --directory /data
curl localhost:8080/hello.txt # "hello from the host" — from Cloudflare
docker volume ls
docker rm -f seeded
A bind mount is seeded: Cloudflare can't mount your filesystem, so the directory is baked into the image and the container gets a writable copy for its lifetime. Nothing syncs back, nothing survives teardown. Edit a seeded file and the next run rebuilds rather than serving stale data.
docker buildDOCKER_BUILDKIT=0 docker build -t myapp .
docker run -d -p 8080:3000 myapp
Your Dockerfile and context are stored on build, then built for linux/amd64 and pushed
the first time a container using that image starts — nothing is built locally, and the
build output says so. Multi-stage works; FROM lines are pinned to linux/amd64 with
stage names and COPY --from preserved. Editing the context changes the image's identity,
so the next run rebuilds.
DOCKER_BUILDKIT=0is required. Export it once next toDOCKER_HOSTand forget it. Docker ≥23 routesdocker buildthrough buildx, which tries to boot a BuildKit container on the daemon. That choice is made client-side, so BridgeFlare can't opt out for you (reporting the classic builder in/infodoesn't stop it — tested). Forget it and you get an error saying exactly this.
Running containers survive a daemon restart — the local proxy and auth secret are
persisted and rebuilt on startup, so curl localhost:8080 keeps working with no re-run.
sudo bridgeflare dns install # /etc/resolver/bridge + a port-80 LaunchDaemon
open http://web.bridge # instead of localhost:8080
bridgeflare dns status
sudo bridgeflare dns uninstall # removes only files it created
macOS routes a whole TLD to a resolver of your choosing, so *.bridge resolves to a local
responder and a port-80 bridge routes by Host header. An unknown name returns a 404
listing the names that do exist.
cargo install --path menubar --locked # lands next to `bridgeflare`, and finds it there
bridgeflare-menubar
Containers grouped by project directory. Each has Open, Copy Cloudflare link, Copy IP and Copy local URL, with Restart All / Kill All / Quit below.
/var/run/docker.sockDOCKER_HOST covers the docker CLI, but not tools that hardcode the socket path (IDE
plugins, testcontainers, some CI runners). For those:
bridgeflare socket status # read-only, no privileges needed
sudo bridgeflare socket take # repoint /var/run/docker.sock at BridgeFlare
sudo bridgeflare socket release # put it back
⚠️ While a takeover is active, every docker command on the machine reaches BridgeFlare. Your real containers aren't listed, and a
docker rmaimed at one of them hits a BridgeFlare container instead.takeprints that warning and asks for confirmation.
It repoints the root-owned symlink at /var/run/docker.sock, recording the original target
in ~/.bridgeflare/socket-takeover.json before the swap. If that path is a real unix
socket rather than a symlink (the usual plain-Linux layout) it refuses, rather than deleting
the daemon's own endpoint. Because the docker CLI resolves through a context, take also
switches to the default context and release restores the one you were on.
# Daemon shell (DOCKER_HOST unset)
bridgeflare welcome # the getting-started walkthrough
bridgeflare serve # start the Docker API on a unix socket
bridgeflare login [--status] # validate a token + write credentials
bridgeflare list [--json] # containers, state, ports, URLs
bridgeflare restart-all # stop then start everything
bridgeflare stop-all # tear everything down (stop billing)
bridgeflare gc [--force] # reap orphaned bf-* resources; dry-run by default
bridgeflare reconcile # repair local state against Cloudflare
bridgeflare --no-color … # also honours NO_COLOR / CLICOLOR / CLICOLOR_FORCE
# Docker shell
docker stats --no-stream web
docker inspect web
docker exec web echo hi # fails cleanly (Cloudflare has no exec) — never hangs
@cloudflare/containers) running
your image, deployed via wrangler.127.0.0.1-only proxy is the
sole holder and injects it, so the public workers.dev route is unusable by anyone else.wss://<worker>/__bf/tcp/<port>, which the Worker bridges to the container via
getTcpPort().connect(). One path carries HTTP and raw protocols, which is why curl,
redis-cli and psql all work.bf-<project>-<service>-<spechash>. Teardown deletes exactly those by id, and gc only
ever considers bf--prefixed resources absent from the ledger — it can never delete
anything it didn't create.bf-, only deletes what its ledger records.
gc defaults to a dry run.0600; nothing binds to a public interface.~/.bridgeflare/credentials.toml (mode 0600), never in the repo.serve announces an active takeover
at startup and again on shutdown, since an unprivileged daemon can't undo it.linux/amd64 only (Cloudflare's arch); no privileged/GPU; ≤ 4 vCPU / 12 GiB / 20 GB
per instance; ephemeral disk.<PEER>_URL. Different ports are unaffected.docker exec and attached-mode docker run aren't supported (they fail cleanly
rather than hang). docker wait reports failure honestly but can't report a process's real
exit code — Cloudflare doesn't expose one.docker logs isn't available (Cloudflare exposes live tail only) — use
docker logs -f./dev/stdout (stock nginx) crash on the runtime.RUN, so shell-less bases (distroless/scratch) can't take mounts.cargo test # no Cloudflare account needed
cargo clippy --all-targets
cargo test --manifest-path agent/Cargo.toml # the in-container mesh agent
Releasing: bump version in Cargo.toml (and let cargo refresh Cargo.lock), move the
CHANGELOG.md entries from ## [Unreleased] under a ## [x.y.z] - date
heading, then merge to main. .github/workflows/release.yml sees that v<version> isn't
tagged yet, runs the tests, builds macOS (arm64 + Intel) and Linux x86_64 tarballs, and
publishes them as a GitHub release with that changelog section as its notes — creating the
tag last, so a failed run is simply retried by the next push. A version with no changelog
section fails in the first job, before anything is built.
A library plus a thin binary (src/main.rs). Cloudflare sits behind a Platform trait with
an in-memory fake, so the whole engine is testable offline. agent/ and menubar/ are
standalone crates — the agent cross-compiles to static musl, and keeping it out of the
workspace keeps rusqlite out of that build. The agent's source is compiled into the
bridgeflare binary so an installed copy can still build it; a test fails if a new agent
file isn't added to that list (EMBEDDED_SOURCE). BF_AGENT_SRC=agent builds from the
checkout instead.
MIT.
51 commits
Rust
100.0%
Swap-in replacement for docker to run your containers on CF
Rust
0
51 commits
updated Sep 20, 2026
A drop-in Docker replacement that runs your containers on Cloudflare Containers and tunnels them back to localhost.
Point the Docker CLI at BridgeFlare instead of your Docker daemon. docker run and
docker compose up provision real containers on Cloudflare, fronted by a Worker and
reachable at localhost:<port> — secured so nobody but you can reach them.
docker rm / docker compose down tears every Cloudflare resource back down.
docker CLI ──unix socket──▶ BridgeFlare ──▶ Cloudflare Worker ──▶ Container
(DOCKER_HOST) │ ▲
localhost:8080 ◀── local proxy (injects per-container auth) ──────────┘
Status: experimental. It runs real workloads — containers,
composeprojects, databases, volumes and local builds — but Cloudflare's runtime imposes real edges. Read Limitations before depending on it.
You'll need: Rust (edition 2024) · Docker Desktop or OrbStack running
(BridgeFlare uses it to build the linux/amd64 image it pushes) · Node.js
and wrangler (npm install -g wrangler) · a Cloudflare account on the Workers Paid plan
(Containers requires it).
bridgeflare serve checks for these on startup and prints the fix for whatever is missing.
Without wrangler it falls back to npx wrangler — slower, but it works.
# 1. Install — puts `bridgeflare` on your PATH (~/.cargo/bin, which rustup set up)
git clone <this repo> && cd bridgeflare
cargo install --path . --locked
# 2. Sign in to Cloudflare
bridgeflare login
# 3. Start the daemon — in a shell with DOCKER_HOST unset,
# because it needs your real Docker to build images.
bridgeflare serve
The installed binary is self-contained — the checkout can go once it's installed.
bridgeflare: command not found? Add ~/.cargo/bin to your PATH. To upgrade, git pull
and run the install again; to remove it, cargo uninstall bridgeflare. Where sudo resets
PATH (Linux's secure_path), write sudo "$(command -v bridgeflare)" … for the few
commands that need root.
In a second terminal, point Docker at it:
export DOCKER_HOST=unix:///Users/$USER/.bridgeflare/bridgeflare.sock
export DOCKER_BUILDKIT=0 # BridgeFlare implements the classic builder — see below
docker version # Server: BridgeFlare
Run something:
docker run -d --name web -p 8080:80 traefik/whoami # ~60s first time: builds + pushes
curl localhost:8080 # 200, served from Cloudflare
docker ps
docker logs -f web # live tail (Ctrl-C to stop)
docker rm -f web # tears down every Cloudflare resource
That's the whole loop. New to it? bridgeflare welcome prints this walkthrough any time.
First run of an image takes ~60s while it is built for
linux/amd64and pushed. Subsequent runs of the same image are fast.
docker compose, with real service namesmkdir -p /tmp/bftest && cd /tmp/bftest
cat > docker-compose.yml <<'YML'
services:
web:
image: ealen/echo-server
ports: ["8094:80"]
db:
image: postgres:16
ports: ["5432:5432"]
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
YML
docker compose up -d
docker compose ps
docker compose down
From inside web, the database is postgresql://app:app@db:5432/app — the same URL your
compose file would use locally, unchanged.
Peers answer to their plain service name, exactly as under real Docker Compose. Cloudflare has no container network, so BridgeFlare bakes a small static agent into each image: it runs a loopback resolver and tunnels each peer port out to that peer's Worker.
The <PEER>_URL, <PEER>_AUTH and BF_MESH environment variables are still injected, so
config-driven apps work too.
Both run over the raw-TCP tunnel, so psql and redis-cli work against localhost:
docker run -d --name db -p 5432:5432 \
-e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app postgres:16
psql -h 127.0.0.1 -U app -d app # your own credentials, honoured
redis runs as-is. Stock postgres needs help — its entrypoint wants a /var/run it
can't write on Cloudflare's rootless runtime — so BridgeFlare generates an image that
drives initdb/postgres directly and honours POSTGRES_USER / POSTGRES_PASSWORD /
POSTGRES_DB.
mkdir -p /tmp/seed && echo "hello from the host" > /tmp/seed/hello.txt
docker run -d --name seeded -p 8080:8080 -v /tmp/seed:/data \
python:3-alpine python3 -m http.server 8080 --directory /data
curl localhost:8080/hello.txt # "hello from the host" — from Cloudflare
docker volume ls
docker rm -f seeded
A bind mount is seeded: Cloudflare can't mount your filesystem, so the directory is baked into the image and the container gets a writable copy for its lifetime. Nothing syncs back, nothing survives teardown. Edit a seeded file and the next run rebuilds rather than serving stale data.
docker buildDOCKER_BUILDKIT=0 docker build -t myapp .
docker run -d -p 8080:3000 myapp
Your Dockerfile and context are stored on build, then built for linux/amd64 and pushed
the first time a container using that image starts — nothing is built locally, and the
build output says so. Multi-stage works; FROM lines are pinned to linux/amd64 with
stage names and COPY --from preserved. Editing the context changes the image's identity,
so the next run rebuilds.
DOCKER_BUILDKIT=0is required. Export it once next toDOCKER_HOSTand forget it. Docker ≥23 routesdocker buildthrough buildx, which tries to boot a BuildKit container on the daemon. That choice is made client-side, so BridgeFlare can't opt out for you (reporting the classic builder in/infodoesn't stop it — tested). Forget it and you get an error saying exactly this.
Running containers survive a daemon restart — the local proxy and auth secret are
persisted and rebuilt on startup, so curl localhost:8080 keeps working with no re-run.
sudo bridgeflare dns install # /etc/resolver/bridge + a port-80 LaunchDaemon
open http://web.bridge # instead of localhost:8080
bridgeflare dns status
sudo bridgeflare dns uninstall # removes only files it created
macOS routes a whole TLD to a resolver of your choosing, so *.bridge resolves to a local
responder and a port-80 bridge routes by Host header. An unknown name returns a 404
listing the names that do exist.
cargo install --path menubar --locked # lands next to `bridgeflare`, and finds it there
bridgeflare-menubar
Containers grouped by project directory. Each has Open, Copy Cloudflare link, Copy IP and Copy local URL, with Restart All / Kill All / Quit below.
/var/run/docker.sockDOCKER_HOST covers the docker CLI, but not tools that hardcode the socket path (IDE
plugins, testcontainers, some CI runners). For those:
bridgeflare socket status # read-only, no privileges needed
sudo bridgeflare socket take # repoint /var/run/docker.sock at BridgeFlare
sudo bridgeflare socket release # put it back
⚠️ While a takeover is active, every docker command on the machine reaches BridgeFlare. Your real containers aren't listed, and a
docker rmaimed at one of them hits a BridgeFlare container instead.takeprints that warning and asks for confirmation.
It repoints the root-owned symlink at /var/run/docker.sock, recording the original target
in ~/.bridgeflare/socket-takeover.json before the swap. If that path is a real unix
socket rather than a symlink (the usual plain-Linux layout) it refuses, rather than deleting
the daemon's own endpoint. Because the docker CLI resolves through a context, take also
switches to the default context and release restores the one you were on.
# Daemon shell (DOCKER_HOST unset)
bridgeflare welcome # the getting-started walkthrough
bridgeflare serve # start the Docker API on a unix socket
bridgeflare login [--status] # validate a token + write credentials
bridgeflare list [--json] # containers, state, ports, URLs
bridgeflare restart-all # stop then start everything
bridgeflare stop-all # tear everything down (stop billing)
bridgeflare gc [--force] # reap orphaned bf-* resources; dry-run by default
bridgeflare reconcile # repair local state against Cloudflare
bridgeflare --no-color … # also honours NO_COLOR / CLICOLOR / CLICOLOR_FORCE
# Docker shell
docker stats --no-stream web
docker inspect web
docker exec web echo hi # fails cleanly (Cloudflare has no exec) — never hangs
@cloudflare/containers) running
your image, deployed via wrangler.127.0.0.1-only proxy is the
sole holder and injects it, so the public workers.dev route is unusable by anyone else.wss://<worker>/__bf/tcp/<port>, which the Worker bridges to the container via
getTcpPort().connect(). One path carries HTTP and raw protocols, which is why curl,
redis-cli and psql all work.bf-<project>-<service>-<spechash>. Teardown deletes exactly those by id, and gc only
ever considers bf--prefixed resources absent from the ledger — it can never delete
anything it didn't create.bf-, only deletes what its ledger records.
gc defaults to a dry run.0600; nothing binds to a public interface.~/.bridgeflare/credentials.toml (mode 0600), never in the repo.serve announces an active takeover
at startup and again on shutdown, since an unprivileged daemon can't undo it.linux/amd64 only (Cloudflare's arch); no privileged/GPU; ≤ 4 vCPU / 12 GiB / 20 GB
per instance; ephemeral disk.<PEER>_URL. Different ports are unaffected.docker exec and attached-mode docker run aren't supported (they fail cleanly
rather than hang). docker wait reports failure honestly but can't report a process's real
exit code — Cloudflare doesn't expose one.docker logs isn't available (Cloudflare exposes live tail only) — use
docker logs -f./dev/stdout (stock nginx) crash on the runtime.RUN, so shell-less bases (distroless/scratch) can't take mounts.cargo test # no Cloudflare account needed
cargo clippy --all-targets
cargo test --manifest-path agent/Cargo.toml # the in-container mesh agent
Releasing: bump version in Cargo.toml (and let cargo refresh Cargo.lock), move the
CHANGELOG.md entries from ## [Unreleased] under a ## [x.y.z] - date
heading, then merge to main. .github/workflows/release.yml sees that v<version> isn't
tagged yet, runs the tests, builds macOS (arm64 + Intel) and Linux x86_64 tarballs, and
publishes them as a GitHub release with that changelog section as its notes — creating the
tag last, so a failed run is simply retried by the next push. A version with no changelog
section fails in the first job, before anything is built.
A library plus a thin binary (src/main.rs). Cloudflare sits behind a Platform trait with
an in-memory fake, so the whole engine is testable offline. agent/ and menubar/ are
standalone crates — the agent cross-compiles to static musl, and keeping it out of the
workspace keeps rusqlite out of that build. The agent's source is compiled into the
bridgeflare binary so an installed copy can still build it; a test fails if a new agent
file isn't added to that list (EMBEDDED_SOURCE). BF_AGENT_SRC=agent builds from the
checkout instead.
MIT.
51 commits
Rust
100.0%