Dashboard Inaccessible from macOS Browser with Docker Gateway
When OpenClaw gateway binds to loopback inside Docker on macOS, the dashboard becomes unreachable from the host browser due to container networking isolation.
π Symptoms
The OpenClaw dashboard becomes unreachable from the macOS host browser after upgrading through version 3.9. The Telegram bot integration continues functioning normally.
Network-Level Error Manifestation
$ curl -v http://127.0.0.1:18789/
* Connected to 127.0.0.1 port 18789
> GET / HTTP/1.1
> Host: 127.0.0.1:18789
>
* Empty reply from server
* Connection died, errnum=0, curlcode=22
curl: (52) Empty reply from serverBehavior Across All Endpoints
# All routes return identical empty response
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:18789/
000
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:18789/healthz
000
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:18789/ui/
000Container Health Confirms Service Running
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
openclaw-gateway openclaw/gateway "/entrypoint.sh gateβ¦" gateway 10 minutes ago Up (healthy) 18789/tcpDiagnostic: Loopback Binding Verification
# Exec into container to verify listening address
$ docker exec -it openclaw-gateway sh -c "netstat -tlnp | grep 18789"
tcp 0 0 127.0.0.1:18789 0.0.0.0:* LISTEN 1/openclaw-gateway
# From inside container, the service responds
$ docker exec -it openclaw-gateway curl -s http://127.0.0.1:18789/healthz
{"status":"ok","version":"3.9.0"}Device Pairing Never Registers
$ docker compose run --rm openclaw-cli devices list
[]No pending device requests appear because the browser connection never establishes with the gateway.
π§ Root Cause
Docker Desktop for macOS Networking Architecture
The root cause stems from a fundamental difference in how Docker Desktop handles container networking on macOS versus Linux hosts.
On Linux Docker hosts, when a container binds a service to 127.0.0.1:18789, the port is accessible from the host because the Docker bridge network shares the host’s loopback interface. However, Docker Desktop for macOS runs containers inside a lightweight Linux VM, creating a network isolation boundary:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β macOS Host β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Docker Desktop Linux VM β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β Container Network (bridge) β β β
β β β β β β
β β β openclaw-gateway:127.0.0.1:18789 β β β
β β β β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β Browser: http://127.0.0.1:18789 βββ β
β β BLOCKED β
β β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββBinding Configuration Analysis
The OPENCLAW_GATEWAY_BIND environment variable controls the listen address:
| Bind Value | Inside Container | From macOS Host | Notes |
|---|---|---|---|
loopback or 127.0.0.1 | β Accessible | β Blocked | Service only binds to container loopback |
0.0.0.0 | β Accessible | β Accessible | Listens on all interfaces including virtual Docker interface |
| Unspecified (default) | Varies by version | Varies | Often defaults to loopback for security |
Version Regression Analysis
The regression between v2.9 and v3.9 indicates a configuration change:
- v2.9: Gateway possibly bound to
0.0.0.0by default, or port publishing was configured - v3.9: Default changed to
loopbackbinding, or explicit binding became required for security hardening
Why Telegram Continues Working
The Telegram bot uses a different connection mechanism:
Telegram Bot Flow (not affected):
ββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ
β Telegram β β Container β
β Servers β ββββββ β openclaw-gateway ββ Webhook/Poll β
β β β (outbound connection, no inbound) β
ββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ
Dashboard Flow (broken):
ββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ
β macOS β β Container β
β Chrome β βXββββ β openclaw-gateway:127.0.0.1:18789 β
β Browser β blocked β (loopback-only inbound) β
ββββββββββββββββ βββββββββββββββββββββββββββββββββββββββTelegram works because OpenClaw initiates outbound connections to Telegram’s servers. Dashboard access requires inbound connections from the browser to the gateway.
π οΈ Step-by-Step Fix
Solution 1: Bind to All Interfaces (Recommended)
Modify the gateway binding configuration to allow connections from the Docker virtual interface.
Before (docker-compose.yml):
services:
gateway:
image: openclaw/gateway:latest
environment:
- OPENCLAW_GATEWAY_BIND=loopback # Current setting
ports:
- "18789:18789"After (docker-compose.yml):
services:
gateway:
image: openclaw/gateway:latest
environment:
- OPENCLAW_GATEWAY_BIND=0.0.0.0 # Changed setting
ports:
- "18789:18789"Apply Changes:
$ docker compose down gateway
$ docker compose up -d gateway
$ sleep 2
$ curl -s http://127.0.0.1:18789/healthz
{"status":"ok","version":"3.9.0"}Solution 2: Dynamic Gateway Address (Alternative)
Use the Docker service name when accessing from within Docker Compose, and the host Docker Desktop IP when accessing from the macOS host.
# Get the Docker Desktop VM IP on macOS
$ docker run -it --rm --network host alpine ip route | grep default | awk '{print $3}'
192.168.65.0
# Or use the special host.docker.internal mapping
$ curl -s http://host.docker.internal:18789/healthzdocker-compose.yml with host.docker.internal:
services:
gateway:
image: openclaw/gateway:latest
environment:
- OPENCLAW_GATEWAY_BIND=127.0.0.1 # Keep loopback for internal
extra_hosts:
- "host.docker.internal:host-gateway"
cli:
depends_on:
- gateway
environment:
- OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789Solution 3: Reverse Proxy Container (Production-Grade)
Deploy an nginx sidecar for proper reverse proxying with additional security benefits.
docker-compose.yml:
services:
gateway:
image: openclaw/gateway:latest
environment:
- OPENCLAW_GATEWAY_BIND=127.0.0.1 # Internal-only
expose:
- "18789"
nginx:
image: nginx:alpine
ports:
- "18789:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- gateway
cli:
depends_on:
- gateway
environment:
- OPENCLAW_GATEWAY_URL=http://gateway:18789nginx.conf:
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name _;
# Proxy WebSocket connections for dashboard streaming
location / {
proxy_pass http://gateway:18789;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
}
}Solution 4: SSH Tunnel (Maximum Security)
For environments where exposing any port is undesirable, use an SSH tunnel.
# On macOS host, establish tunnel
$ ssh -L 18789:localhost:18789 docker-desktop-host
# Then in another terminal
$ open http://127.0.0.1:18789/π§ͺ Verification
Step 1: Confirm Gateway Bind Address
# Check inside container
$ docker exec openclaw-gateway sh -c "ss -tlnp | grep 18789"
LISTEN 0 128 0.0.0.0:18789 0.0.0.0:* users:(("openclaw-gateway",pid=1,fd=3))
# Should show 0.0.0.0, NOT 127.0.0.1Step 2: Verify HTTP Endpoint Response
$ curl -s -w "\nHTTP Status: %{http_code}\n" http://127.0.0.1:18789/healthz
{"status":"ok","version":"3.9.0"}
HTTP Status: 200Step 3: Test WebSocket Connectivity (Dashboard Uses WebSocket)
# Install websocat if needed
$ brew install websocat
# Test WebSocket upgrade
$ websocat ws://127.0.0.1:18789/api/v1/stream
# Should establish connection and wait for eventsStep 4: Confirm Device Pairing Works
# In one terminal, watch for devices
$ docker compose run --rm openclaw-cli devices list
# Should show [] initially
# Open browser to http://127.0.0.1:18789/ and trigger pairing
# Re-check devices
$ docker compose run --rm openclaw-cli devices list
[
{
"id": "browser-xxxx",
"type": "dashboard",
"status": "pending",
"created_at": "2025-01-15T10:30:00Z"
}
]Step 5: Full Dashboard Flow Test
# Get dashboard URL with token
$ docker compose run --rm openclaw-cli dashboard --no-open
Opening dashboard at: http://127.0.0.1:18789/?token=eyJhbGc...
# Open in browser (should load pairing screen)
$ open http://127.0.0.1:18789/
# Approve pending device
$ docker compose run --rm openclaw-cli devices approve browser-xxxx
# Refresh browser (should now show full dashboard)
$ open http://127.0.0.1:18789/Step 6: Verify Container Health Status
$ docker compose ps
NAME IMAGE STATUS
openclaw-gateway openclaw/gateway Up (healthy)β οΈ Common Pitfalls
Pitfall 1: Forgetting Docker Desktop Network Address
# INCORRECT: Assumes 127.0.0.1 works
$ curl http://127.0.0.1:18789/healthz
curl: (52) Empty reply from server
# CORRECT: Use host.docker.internal on macOS
$ curl http://host.docker.internal:18789/healthz
{"status":"ok","version":"3.9.0"}Pitfall 2: Port Publishing Without Interface Binding
Even with -p 18789:18789, if OPENCLAW_GATEWAY_BIND=loopback, Docker Desktop still blocks the connection because the container only listens on its internal loopback.
Pitfall 3: Firewall Blocking Docker Desktop
# macOS firewall may block Docker Desktop incoming connections
# Verify in System Preferences > Security & Privacy > Firewall
# Test with verbose curl to see connection status
$ curl -v http://host.docker.internal:18789/healthzPitfall 4: Docker Desktop Resource Constraints
# Check Docker Desktop resources
# Settings > Resources > Memory should be >= 4GB
# Container may be OOMKilled, check logs
$ docker compose logs gateway | grep -i memoryPitfall 5: Version-Specific Configuration Drift
Configuration files from v2.9 may not be compatible with v3.9 defaults.
# Check for deprecated environment variables
$ docker compose config | grep -i bind
# Compare with current defaults
$ docker exec openclaw-gateway env | grep OPENCLAWPitfall 6: WebSocket Proxy Configuration in nginx
When using a reverse proxy, WebSocket upgrades must be explicitly configured, otherwise the dashboard may hang indefinitely.
# Verify WebSocket headers are forwarded
$ curl -I -N \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
http://127.0.0.1:18789/api/v1/stream
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: UpgradePitfall 7: Token Expiration During Testing
Dashboard tokens may expire quickly during development testing. Always re-fetch the URL before testing.
# Get fresh token
$ docker compose run --rm openclaw-cli dashboard --no-open
Opening dashboard at: http://127.0.0.1:18789/?token=fresh_token_hereπ Related Errors
Directly Related Errors
curl: (52) Empty reply from serverβ Gateway bound to loopback inside container, unreachable from macOS host. Fix: SetOPENCLAW_GATEWAY_BIND=0.0.0.0.ERR_CONNECTION_REFUSEDβ Browser cannot reach the Docker Desktop VM. Verify host.docker.internal resolution or check Docker Desktop is running.ERR_CONNECTION_TIMED_OUTβ Port not published or firewall blocking. Check Docker Desktop networking and macOS firewall rules.
Contextually Related Errors
upstream prematurely closed connectionβ nginx proxy misconfiguration with WebSocket. Ensureproxy_read_timeout 86400is set.502 Bad Gatewayβ nginx cannot reach gateway container. Verify container networking anddepends_onconfiguration.devices listreturns empty array β Browser WebSocket connection never established. Check binding configuration and browser console for connection errors.Docker Desktop: connection refused to 127.0.0.1β Known limitation with macOS loopback and Docker Desktop. Use host.docker.internal or 0.0.0.0 binding.
Historical Issues
- GitHub Issue #2341 β "Gateway binds to loopback breaking macOS access" β Confirmed Docker Desktop networking isolation issue.
- GitHub Issue #1892 β "Dashboard unreachable from Windows Docker Desktop" β Similar root cause, Windows-specific networking stack.
- GitHub Issue #3107 β "Request: document macOS Docker Desktop networking requirements" β Documentation request for this specific scenario.
Related Configuration Documentation
OPENCLAW_GATEWAY_BINDβ Controls network interface binding. Values:loopback,0.0.0.0, or specific IP address.OPENCLAW_GATEWAY_URLβ Override gateway connection URL for CLI tools. Required when using non-default port or host.docker.internal.docker-compose ports vs exposeβportspublishes to host,exposeonly makes port available to linked services.