ianrumac/bridgeflare

Swap-in replacement for docker to run your containers on CF

Rust

0

51 commits

updated Sep 20, 2026

See the code

See what people are saying (1)

README

BridgeFlare

BridgeFlare

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, compose projects, 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/amd64 and 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, 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.

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 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=0 is required. Export it once next to DOCKER_HOST and forget it. Docker ≥23 routes docker build through 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 /info doesn'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-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.

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 a docker rm aimed at one of them hits a BridgeFlare container instead. take prints 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 via wrangler.
  • 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 public workers.dev route 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 via getTcpPort().connect(). One path carries HTTP and raw protocols, which is why curl, redis-cli and psql all work.
  • BridgeFlare keeps a SQLite ledger of every resource it creates, named 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.

Safety

  • Only ever creates resources prefixed bf-, only deletes what its ledger records. gc defaults to a dry run.
  • The daemon socket is 0600; nothing binds to a public interface.
  • Credentials live in ~/.bridgeflare/credentials.toml (mode 0600), 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. serve announces an active takeover at startup and again on shutdown, since an unprivileged daemon can't undo it.

Limitations

  • linux/amd64 only (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 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.
  • Historical docker logs isn't available (Cloudflare exposes live tail only) — use docker logs -f.
  • Images that symlink their logs to /dev/stdout (stock nginx) 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.

Contributors

ianrumac

51 commits

ianrumac/bridgeflare

Swap-in replacement for docker to run your containers on CF

Rust

0

51 commits

updated Sep 20, 2026

See the code

See what people are saying (1)

README

BridgeFlare

BridgeFlare

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, compose projects, 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/amd64 and 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, 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.

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 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=0 is required. Export it once next to DOCKER_HOST and forget it. Docker ≥23 routes docker build through 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 /info doesn'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-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.

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 a docker rm aimed at one of them hits a BridgeFlare container instead. take prints 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 via wrangler.
  • 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 public workers.dev route 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 via getTcpPort().connect(). One path carries HTTP and raw protocols, which is why curl, redis-cli and psql all work.
  • BridgeFlare keeps a SQLite ledger of every resource it creates, named 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.

Safety

  • Only ever creates resources prefixed bf-, only deletes what its ledger records. gc defaults to a dry run.
  • The daemon socket is 0600; nothing binds to a public interface.
  • Credentials live in ~/.bridgeflare/credentials.toml (mode 0600), 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. serve announces an active takeover at startup and again on shutdown, since an unprivileged daemon can't undo it.

Limitations

  • linux/amd64 only (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 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.
  • Historical docker logs isn't available (Cloudflare exposes live tail only) — use docker logs -f.
  • Images that symlink their logs to /dev/stdout (stock nginx) 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.

Contributors

ianrumac

51 commits

Languages

Rust

100.0%