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.
Quickstart
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.
What works
docker compose, with real service names
mkdir -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.
Databases
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, honouredredis 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.
Volumes
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 build
DOCKER_BUILDKIT=0 docker build -t myapp .
docker run -d -p 8080:3000 myappYour 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.
Restart-persistence
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.
Extras
Browse containers by name
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.
macOS menu bar app
cargo install --path menubar --locked # lands next to `bridgeflare`, and finds it there
bridgeflare-menubarContainers grouped by project directory. Each has Open, Copy Cloudflare link, Copy IP and Copy local URL, with Restart All / Kill All / Quit below.
Taking over /var/run/docker.sock
DOCKER_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 adocker 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.
Command reference
# 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
How it works
- Each container becomes a Worker + Durable Object (
@cloudflare/containers) running your image, deployed viawrangler. - The Worker is guarded by a per-container secret. A local
127.0.0.1-only proxy is the sole holder and injects it, so the publicworkers.devroute is unusable by anyone else. - Every published port is tunnelled as raw TCP over a WebSocket: the proxy pipes bytes
over
wss://<worker>/__bf/tcp/<port>, which the Worker bridges to the container viagetTcpPort().connect(). One path carries HTTP and raw protocols, which is whycurl,redis-cliandpsqlall work. - BridgeFlare keeps a SQLite ledger of every resource it creates, named
bf-<project>-<service>-<spechash>. Teardown deletes exactly those by id, andgconly ever considersbf--prefixed resources absent from the ledger — it can never delete anything it didn't create.
Safety
- Only ever creates resources prefixed
bf-, only deletes what its ledger records.gcdefaults to a dry run. - The daemon socket is
0600; nothing binds to a public interface. - Credentials live in
~/.bridgeflare/credentials.toml(mode0600), never in the repo. - The socket takeover is opt-in, root-gated, interactively confirmed and reversible: the
original target is recorded before the swap, the swap is atomic, and BridgeFlare never
deletes a real socket or a symlink it didn't record.
serveannounces an active takeover at startup and again on shutdown, since an unprivileged daemon can't undo it.
Limitations
linux/amd64only (Cloudflare's arch); no privileged/GPU; ≤ 4 vCPU / 12 GiB / 20 GB per instance; ephemeral disk.- Nothing persists past a container's life. Volumes are writable for the container's lifetime only — that's a deliberate scope decision, not a gap. A database re-initialises on every cold start.
- Two services publishing the same container port can't reach each other by service
name. A port has one listener, and your own server has to win; they stay reachable via
<PEER>_URL. Different ports are unaffected. docker execand attached-modedocker runaren't supported (they fail cleanly rather than hang).docker waitreports failure honestly but can't report a process's real exit code — Cloudflare doesn't expose one.- Historical
docker logsisn't available (Cloudflare exposes live tail only) — usedocker logs -f. - Images that symlink their logs to
/dev/stdout(stocknginx) crash on the runtime. - Volume seeding uses
RUN, so shell-less bases (distroless/scratch) can't take mounts. - A held-open database connection keeps the container awake — and billing — until closed.
- A cold database may need one connect retry (~10s) while it initialises.
Development
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.
License
MIT.