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
cloudflaredCLI installed on the host for tunnel setup. openssl,curl, and a PNG image for verification.
Check your Compose version:
docker compose version
On macOS, you can install cloudflared with Homebrew:
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:
Client → Cloudflare HTTPS → cloudflared → NGINX:8080 → withoutBG:8000
The three containers have separate responsibilities:
| Service | Responsibility | Network |
|---|---|---|
withoutbg | Load the model and process images | backend |
proxy | Check the API key and forward requests | backend, edge |
cloudflared | Connect to Cloudflare and route requests to NGINX | edge |
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:
mkdir -p withoutbg/nginx withoutbg/cloudflared
cd withoutbg
Log in to Cloudflare and create a dedicated tunnel:
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:
touch .env
chmod 600 .env
openssl rand -hex 32
Copy the generated key and tunnel UUID into .env:
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:
.env
2. Configure Docker Compose
Create compose.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:
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:
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:
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:
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:
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:
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:
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:
docker compose logs -f
| Symptom | What to check |
|---|---|
| Proxy exits at startup | The API key must contain exactly 64 lowercase hexadecimal characters. |
| Model remains unready | Inspect withoutbg logs and the host’s available CPU and memory. |
| Local requests work, public requests fail | Check the tunnel credentials, hostname, DNS record, and registered connections. |
HTTP 413 | The request may exceed NGINX’s 50m limit or a limit at Cloudflare. |
HTTP 524 | Cloudflare connected to the origin but a read or write timed out. |
Cloudflare error 1033 | Cloudflare 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:
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:
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!