Deploy withoutBG locally with Docker and Cloudflare Tunnel

Deploy withoutBG locally with Docker and Cloudflare Tunnel
Deploy withoutBG locally with Docker and Cloudflare Tunnel

Introduction

In this guide, I’ll walk through that setup using Docker Compose, NGINX, and Cloudflare Tunnel. NGINX checks an API key before forwarding background-removal requests, and the tunnel connects the local machine to Cloudflare without opening a port on the router.

The configuration is adapted from my withoutbg deployment repository. I’ve included the files below so you can follow the guide without repository access.

This setup uses the CPU service image, which provides an API and Swagger documentation at /docs. The separate app image includes a drag-and-drop interface. See the withoutBG Docker documentation for the available images.

Prerequisites

You need:

  • A macOS or Linux machine with Docker running. Docker Desktop works for a local workstation.
  • A current Docker Compose v2 installation. The configuration uses start_interval, supported by Compose 2.20.2 and later.
  • A Cloudflare account and a domain managed through Cloudflare DNS.
  • The cloudflared CLI installed on the host for tunnel setup.
  • openssl, curl, and a PNG image for verification.

Check your Compose version:

sh
docker compose version

On macOS, you can install cloudflared with Homebrew:

sh
brew install cloudflared

For Linux, follow Cloudflare’s installation instructions. The Compose health-check options are documented in the Docker service reference.

The examples use withoutbg.example.com. Replace it with a subdomain you control. Keep the host awake and Docker running while serving requests.

How the services connect

Requests follow this path:

text
Client → Cloudflare HTTPS → cloudflared → NGINX:8080 → withoutBG:8000

The three containers have separate responsibilities:

ServiceResponsibilityNetwork
withoutbgLoad the model and process imagesbackend
proxyCheck the API key and forward requestsbackend, edge
cloudflaredConnect to Cloudflare and route requests to NGINXedge

The backend network is internal, and the model container publishes no host port. The model ships inside the image, so inference does not need an external model download at startup. Docker still needs internet access to pull the image.

NGINX publishes 127.0.0.1:8080 for local checks. The tunnel reaches NGINX through the Docker network using http://proxy:8080.

Inference runs locally, but images submitted through the public hostname pass through Cloudflare on their way to your machine.

1. Create the tunnel and environment file

Create a working directory:

sh
mkdir -p withoutbg/nginx withoutbg/cloudflared
cd withoutbg

Log in to Cloudflare and create a dedicated tunnel:

sh
cloudflared tunnel login
cloudflared tunnel create withoutbg-local

Follow the browser login flow and select your domain. The login creates ~/.cloudflared/cert.pem. Creating the tunnel prints its UUID and writes a credentials file such as ~/.cloudflared/<TUNNEL_UUID>.json.

The account certificate manages tunnels; the JSON credentials run one specific tunnel. Only the JSON file will be mounted into the running container. Cloudflare explains the distinction in its tunnel permissions documentation.

Use a new tunnel for this deployment. Reusing an existing tunnel’s credentials would add another connector to that tunnel and could send its traffic to this machine.

Create a private .env file:

sh
touch .env
chmod 600 .env
openssl rand -hex 32

Copy the generated key and tunnel UUID into .env:

dotenv
WITHOUTBG_API_KEY=REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS
WITHOUTBG_TUNNEL_ID=REPLACE_WITH_YOUR_TUNNEL_UUID

The proxy requires exactly 64 lowercase hexadecimal characters. If you are moving an existing service, you can retain its key from your secret manager so existing clients continue working.

Create .gitignore with:

gitignore
.env

2. Configure Docker Compose

Create compose.yaml:

yaml
name: withoutbg

services:
  withoutbg:
    image: withoutbg/withoutbg-openweights-v3-service-cpu:latest
    restart: unless-stopped
    networks:
      - backend
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:8000/ready']
      interval: 5s
      timeout: 2s
      retries: 3
      start_period: 300s
      start_interval: 5s

  proxy:
    image: nginx:1.28-alpine
    restart: unless-stopped
    entrypoint:
      - /bin/sh
      - -ec
      - |
        case "$$WITHOUTBG_API_KEY" in
          ""|*[!0-9a-f]*) echo "API key must be 64 lowercase hex characters" >&2; exit 1 ;;
        esac
        test "$${#WITHOUTBG_API_KEY}" -eq 64
        exec /docker-entrypoint.sh nginx -g 'daemon off;'
    environment:
      WITHOUTBG_API_KEY: ${WITHOUTBG_API_KEY:?set WITHOUTBG_API_KEY in .env}
      NGINX_ENVSUBST_FILTER: ^WITHOUTBG_API_KEY$$
    volumes:
      - ./nginx:/etc/nginx/templates:ro
    ports:
      - 127.0.0.1:8080:8080
    networks:
      - backend
      - edge
    depends_on:
      withoutbg:
        condition: service_healthy

  cloudflared:
    image: cloudflare/cloudflared:2026.10.3
    restart: unless-stopped
    command: tunnel --config /etc/cloudflared/config.yml run ${WITHOUTBG_TUNNEL_ID:?set WITHOUTBG_TUNNEL_ID in .env}
    volumes:
      - ./cloudflared/config.yml:/etc/cloudflared/config.yml:ro
      - ~/.cloudflared/${WITHOUTBG_TUNNEL_ID}.json:/etc/cloudflared/credentials.json:ro
    networks:
      - edge
    depends_on:
      - proxy

networks:
  backend:
    internal: true
  edge:

The readiness check waits for the model to warm up. start_period: 300s gives startup failures a five-minute grace period; it does not force a five-minute delay. NGINX can start as soon as the model becomes healthy. Docker documents this behavior under Compose startup order.

The double dollar signs escape Compose interpolation. They let the container’s shell evaluate the key and its length. NGINX_ENVSUBST_FILTER restricts template substitution to WITHOUTBG_API_KEY, preserving NGINX variables such as $host and $request_uri.

The image tags above match the deployment repository at the time of writing. The model uses latest; pin a tested image digest if you need repeatable updates.

3. Add the NGINX API-key check

Create nginx/default.conf.template:

nginx
map $http_x_api_key $api_key_valid {
    default 0;
    "" 0;
    ~^${WITHOUTBG_API_KEY}$ 1;
}

server {
    listen 8080;
    server_name _;
    client_max_body_size 50m;

    resolver 127.0.0.11 valid=10s ipv6=off;
    set $withoutbg http://withoutbg:8000;

    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-API-Key "";
    proxy_read_timeout 300s;

    if ($http_x_forwarded_proto = http) {
        return 301 https://$host$request_uri;
    }

    location ~ ^/v1/remove-background/?$ {
        if ($api_key_valid = 0) {
            return 401;
        }

        proxy_pass $withoutbg;
    }

    location / {
        proxy_pass $withoutbg;
    }
}

This configuration protects /v1/remove-background and its trailing-slash variant. Missing or incorrect keys receive HTTP 401. After validation, NGINX removes X-API-Key before forwarding the request to the model service.

Other paths, including /docs, /health, and /ready, remain accessible without a key. This is endpoint-specific protection. Review the routes when updating the model service, particularly if a future release adds another processing endpoint.

Docker’s DNS resolver lets NGINX look up the backend again when its container is recreated with a different IP address. The upload limit is 50m (50 MiB).

The HTTPS redirect uses the scheme Cloudflare received from the client. Local checks over http://127.0.0.1:8080 work without that forwarded header.

4. Configure the tunnel destination

Create cloudflared/config.yml, replacing the hostname:

yaml
credentials-file: /etc/cloudflared/credentials.json

ingress:
  - hostname: withoutbg.example.com
    service: http://proxy:8080
  - service: http_status:404

Use proxy:8080 here. Inside the cloudflared container, localhost refers to the tunnel container itself.

The final rule returns 404 for unmatched hostnames. The tunnel UUID comes from the Compose command, while the credentials path matches the mounted JSON file.

5. Start the service and verify it locally

Validate the configuration and start the containers:

sh
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --tail=100 withoutbg proxy cloudflared

Use --quiet when validating so Compose does not print the expanded API key. Check that withoutbg becomes healthy and the tunnel logs show registered connections. Compose starting the containers does not, by itself, verify public access.

Check the local endpoints:

sh
WITHOUTBG_URL=http://127.0.0.1:8080

curl -i "$WITHOUTBG_URL/health"
curl -i "$WITHOUTBG_URL/ready"
curl -i "$WITHOUTBG_URL/docs"
curl -i -X POST "$WITHOUTBG_URL/v1/remove-background"
curl -i -X POST \
  -H 'X-API-Key: invalid' \
  "$WITHOUTBG_URL/v1/remove-background/"

Expect 200 from the first three requests once the service is ready, and 401 from both background-removal requests.

Next, load the variables from the .env file you created and submit a PNG:

sh
set -a
. ./.env
set +a

curl --fail-with-body \
  "$WITHOUTBG_URL/v1/remove-background" \
  -H "X-API-Key: $WITHOUTBG_API_KEY" \
  -H 'Content-Type: image/png' \
  --data-binary @photo.png \
  -o result.png

Replace photo.png with your input file. A successful raw-image request returns a PNG with a transparent background. Open result.png and inspect the cutout. This request format is described in the withoutBG raw image API documentation.

The key is enforced by NGINX in this deployment. You generate it yourself; it is not a subscription key for withoutBG’s hosted API.

6. Make the service accessible over HTTPS

After the local checks pass, create the DNS route:

sh
cloudflared tunnel route dns withoutbg-local withoutbg.example.com

This creates a CNAME pointing to your tunnel’s <TUNNEL_UUID>.cfargotunnel.com address. If the hostname already has a DNS record, save its current configuration before replacing it with a proxied CNAME in the Cloudflare dashboard. See Cloudflare’s locally managed tunnel guide for the routing procedure.

Switch to the public URL and repeat the checks:

sh
WITHOUTBG_URL=https://withoutbg.example.com

curl -i "$WITHOUTBG_URL/ready"
curl -i -X POST "$WITHOUTBG_URL/v1/remove-background"
curl -i -X POST \
  -H 'X-API-Key: invalid' \
  "$WITHOUTBG_URL/v1/remove-background/"
curl -i http://withoutbg.example.com/health

curl --fail-with-body \
  "$WITHOUTBG_URL/v1/remove-background" \
  -H "X-API-Key: $WITHOUTBG_API_KEY" \
  -H 'Content-Type: image/png' \
  --data-binary @photo.png \
  -o result-public.png

Expect readiness to return 200, both unauthorized requests to return 401, and the HTTP request to redirect to HTTPS. The NGINX rule returns 301; Cloudflare rules may redirect the request before it reaches NGINX.

Repeat a request from another network, such as a laptop connected through a mobile hotspot, to verify access from outside your local network.

Keep the shared API key in trusted scripts or backend services. Embedding it in a public frontend would expose it to visitors.

Maintenance and troubleshooting

View logs when requests fail:

sh
docker compose logs -f
SymptomWhat to check
Proxy exits at startupThe API key must contain exactly 64 lowercase hexadecimal characters.
Model remains unreadyInspect withoutbg logs and the host’s available CPU and memory.
Local requests work, public requests failCheck the tunnel credentials, hostname, DNS record, and registered connections.
HTTP 413The request may exceed NGINX’s 50m limit or a limit at Cloudflare.
HTTP 524Cloudflare connected to the origin but a read or write timed out.
Cloudflare error 1033Cloudflare cannot find a healthy tunnel connector. Check Docker, the network, and whether the machine is asleep.

NGINX’s proxy_read_timeout 300s does not extend Cloudflare’s timeout. At the time of writing, Cloudflare documents a default proxy read timeout of 125 seconds and a proxy write timeout of 30 seconds. Long inference requests can therefore fail through the public URL even when they succeed locally. See the error 524 documentation and tunnel troubleshooting guide.

Pull updated images and recreate affected containers:

sh
docker compose pull
docker compose up -d

Then repeat the readiness, authentication, and image-processing checks. A pull only updates the tags already configured; changing the pinned cloudflared version requires editing compose.yaml.

To rotate the API key, update .env and run docker compose up -d. Compose recreates the proxy with the new environment. Update clients at the same time because this configuration accepts only one key.

After editing a bind-mounted configuration file, restart the service that reads it:

sh
docker compose restart proxy
docker compose restart cloudflared

The restart policy handles processes that exit. It does not restart a container merely because its health check becomes unhealthy. This is a single-machine deployment, so availability also depends on the host, Docker, and the internet connection.

Conclusion

This setup runs withoutBG inference on a local machine and exposes the API through an HTTPS hostname. NGINX handles the shared API key, while Cloudflare Tunnel provides the public connection.

Before connecting an application, verify a complete image request through the public hostname and confirm that missing and incorrect keys both receive 401. Keep a known test image for repeating those checks after updates.

If you found this useful, you can buy me a coffee! Thanks for the support!