Deploying OpenClaw with Docker: A Complete Journey from Pitfalls to Success
This post documents the full process of deploying the OpenClaw Agent on macOS Docker Desktop, including Tailscale sidecar networking, Gateway configuration, Dashboard connection, and solutions to common issues.
Background
OpenClaw is an AI Agent framework open-sourced at the end of 2025, with over 145k stars on GitHub. It features a Gateway (persistent daemon) + CLI (on-demand interaction) architecture, supports multi-model integration, scheduled Heartbeat tasks, persistent Memory, and other enterprise-grade features.
The official Docker image is alpine/openclaw:latest, but real-world deployment often runs into issues not fully covered by the docs. This post is a hands-on guide after "stepping on every rake."
Environment
- macOS + Docker Desktop
- Tailscale for accessing internal services (e.g., self-hosted Gitea)
- OpenAI API (gpt-4o)
Architecture Overview
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌───────────┐ network_mode:service │
│ │ Tailscale │◄────────────────────────┐ │
│ │ Sidecar │ shared network ns │ │
│ └─────┬─────┘ │ │
│ │ ports: 18790:18790 │ │
│ │ ┌─────┴──┐ │
│ │ │OpenClaw│ │
│ │ │Gateway │ │
│ │ └────────┘ │
│ │ │
│ ┌─────┴──────┐ │
│ │ OpenClaw │ profiles: [cli] │
│ │ CLI │ on-demand │
│ └────────────┘ │
└─────────────────────────────────────────────┘
Key decision: The Gateway shares the Tailscale container's network stack via network_mode: "service:tailscale", enabling direct access to services inside the Tailnet.
docker-compose.yml
services:
tailscale:
image: tailscale/tailscale:latest
container_name: my-ts
hostname: my-agent
restart: unless-stopped
cap_add:
- NET_ADMIN
- NET_RAW
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ts-state:/var/lib/tailscale
env_file:
- ./secrets/.env
environment:
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
- TS_ACCEPT_DNS=true
ports:
- "18790:18790"
extra_hosts:
- "my-server:100.x.x.x" # Hardcoded Tailscale IP
openclaw-gateway:
image: alpine/openclaw:latest
container_name: my-gateway
restart: unless-stopped
depends_on:
- tailscale
network_mode: "service:tailscale"
env_file:
- ./secrets/.env
environment:
HOME: /home/node
TERM: xterm-256color
volumes:
- openclaw-config:/home/node/.openclaw
- openclaw-workspace:/home/node/.openclaw/workspace
init: true
security_opt:
- no-new-privileges:true
command:
["node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18790"]
openclaw-cli:
image: alpine/openclaw:latest
container_name: my-cli
env_file:
- ./secrets/.env
environment:
HOME: /home/node
TERM: xterm-256color
BROWSER: echo
volumes:
- openclaw-config:/home/node/.openclaw
- openclaw-workspace:/home/node/.openclaw/workspace
stdin_open: true
tty: true
init: true
entrypoint: ["node", "dist/index.js"]
profiles:
- cli
volumes:
ts-state:
openclaw-config:
openclaw-workspace:
Pitfall Log
Pitfall 1: Image Name and Entrypoint
The official Docker Hub image is alpine/openclaw, not openclaw/openclaw.
There's no standalone openclaw binary in the container. It's a Node.js app, with entrypoints:
node dist/index.js # CLI mode
node dist/index.js gateway # Gateway mode
See the official docker-compose.yml for confirmation.
Pitfall 2: File Permissions (EACCES)
When a Docker named volume is created, the directory is owned by root, but OpenClaw runs as the node user (uid 1000):
EACCES: permission denied, open '/home/node/.openclaw/.env'
Solution:
docker compose run --rm -u root --entrypoint sh openclaw-cli \
-c "chown -R node:node /home/node/.openclaw"
Note: You must override the entrypoint, or it'll default to running node dist/index.js.
Pitfall 3: Tailscale DNS Not Working
Even with TS_ACCEPT_DNS=true, the container can't resolve Tailscale hostnames:
curl: (6) Could not resolve host: my-server
Root cause: macOS Docker Desktop's /dev/net/tun support is incomplete, so Tailscale's kernel-mode DNS interception may not work.
Solution: Hardcode the Tailscale IP in extra_hosts in docker-compose.yml:
extra_hosts:
- "my-server:100.x.x.x"
Get the IP from tailscale status. Not elegant, but 100% reliable.
Pitfall 4: Gateway Startup Blocked
Gateway start blocked: set gateway.mode=local (current: unset) or pass --allow-unconfigured.
Solution: Explicitly declare in openclaw.json:
{
"gateway": {
"mode": "local"
}
}
Pitfall 5: Config Format Migration
If you follow old docs, you might write:
{
"agent": {
"model": "openai/o3"
}
}
OpenClaw 2026.2.x will throw:
agent.* was moved; use agents.defaults instead
agent.model string was replaced by agents.defaults.model.primary/fallbacks
Correct format:
{
"agents": {
"defaults": {
"model": {
"primary": "openai/gpt-4o",
"fallbacks": ["openai/gpt-4o-mini"]
}
}
}
}
Also, compaction.mode: "aggressive" is no longer valid -- just remove it and use the default.
Pitfall 6: The --bind Parameter
Gateway's --bind only accepts these keywords:
| Value | Meaning |
|---|---|
loopback |
127.0.0.1, local only |
lan |
0.0.0.0, required in Docker |
tailnet |
Bind to Tailscale iface |
auto |
Auto-select |
custom |
Specify exact IP |
You cannot pass 0.0.0.0 directly -- use lan instead.
Pitfall 7: Dashboard "pairing required" (1008)
This is the trickiest. The Dashboard page loads, but the WebSocket disconnects immediately:
disconnected (1008): pairing required
Root cause: The browser accesses via localhost:18790, but after Docker NAT + Tailscale forwarding, the Gateway sees the remote IP as a Tailscale DERP relay IP (e.g., 172.105.x.x), not 127.0.0.1. The Gateway treats this as an external connection and triggers device pairing.
Tried but ineffective:
trustedProxies: ["0.0.0.0/0"]-- only controls whether proxy headers are trusted, does not bypass pairing
Final solution: Add to openclaw.json:
{
"gateway": {
"controlUi": {
"enabled": true,
"allowInsecureAuth": true
}
}
}
allowInsecureAuth: true lets the Dashboard accept token-only auth over HTTP, bypassing device identity and pairing checks.
Pitfall 8: Port Conflicts
If another OpenClaw instance on the host is using the default port 18789, the Gateway will fail to start. Use another port (e.g., 18790), and update all three places:
- The
--portargument indocker-compose.ymlcommand - The
portsmapping for tailscale indocker-compose.yml - The
gateway.portinopenclaw.json
All three must match.
Onboarding Process
The first run requires the onboarding wizard for initialization:
# Stop the gateway first to avoid repeated restarts before init
docker compose stop openclaw-gateway
# Run onboarding
docker compose run --rm openclaw-cli onboard
The wizard will prompt:
| Step | Recommendation |
|---|---|
| Security acknowledgment | Confirm |
| QuickStart / Advanced | QuickStart |
| Model provider | OpenAI |
| Default model | Choose as needed (gpt-4o is stable, o3 needs org validation) |
| Channel (WhatsApp/Telegram etc.) | Skip |
| Skill dependencies (API Keys) | All No |
| Hooks | Skip for now |
Afterwards, start the Gateway:
docker compose up -d
Get the Dashboard URL:
docker compose run --rm openclaw-cli dashboard --no-open
Final openclaw.json
{
"gateway": {
"mode": "local",
"bind": "lan",
"port": 18790,
"trustedProxies": ["0.0.0.0/0"],
"controlUi": {
"enabled": true,
"allowInsecureAuth": true
}
},
"agents": {
"defaults": {
"model": {
"primary": "openai/gpt-4o",
"fallbacks": ["openai/gpt-4o-mini"]
},
"heartbeat": {
"every": "24h",
"model": "openai/gpt-4o-mini",
"prompt": "Check workspace for pending tasks. Execute if needed, otherwise reply HEARTBEAT_OK."
}
}
}
}
SSH Configuration Inside the Container (for Git Access)
If you want the Agent to push code to a self-hosted Git service:
# Generate key inside the container
docker compose exec openclaw-gateway \
ssh-keygen -t ed25519 -C "openclaw@agent" -f /home/node/.ssh/id_ed25519 -N ""
# View public key and add to Git service
docker compose exec openclaw-gateway cat /home/node/.ssh/id_ed25519.pub
# Configure SSH (example: Gitea on port 2222)
docker compose exec openclaw-gateway bash -c 'cat >> /home/node/.ssh/config << EOF
Host my-server
Port 2222
User git
IdentityFile /home/node/.ssh/id_ed25519
EOF'
# Set git user info
docker compose exec openclaw-gateway bash -c \
'git config --global user.name "openclaw" && git config --global user.email "[email protected]"'
Note: The SSH key is stored in the container's filesystem, not in a named volume. If the container is rebuilt, you'll need to regenerate it. For persistence, mount an extra volume to /home/node/.ssh.
Data Persistence
| Volume | Container Path | Contents |
|---|---|---|
ts-state |
/var/lib/tailscale |
Tailscale node state |
openclaw-config |
/home/node/.openclaw |
Agent config, sessions, memory |
openclaw-workspace |
/home/node/.openclaw/workspace |
Workspace files |
Inject config files (openclaw.json, SOUL.md, etc.) from the host:
docker compose cp config/openclaw.json openclaw-gateway:/home/node/.openclaw/openclaw.json
docker compose cp config/SOUL.md openclaw-gateway:/home/node/.openclaw/workspace/SOUL.md
Quick Reference for Daily Operations
# Start/stop
docker compose up -d
docker compose down
# Enter container
docker compose exec openclaw-gateway bash # as node user
docker compose exec -u root openclaw-gateway bash # as root
# CLI operations
docker compose run --rm openclaw-cli status
docker compose run --rm openclaw-cli dashboard --no-open
# View logs
docker compose logs -f openclaw-gateway
# Copy config to container
docker compose cp config/openclaw.json openclaw-gateway:/home/node/.openclaw/openclaw.json
docker compose restart openclaw-gateway
Summary
The real challenges in deploying OpenClaw with Docker aren't with the Agent itself, but with networking and authentication:
- Tailscale sidecar DNS is unreliable -- fix with hardcoded
extra_hosts - Docker NAT causes Dashboard pairing failures -- fix with
controlUi.allowInsecureAuth - Config format migration -- watch for
agent.*toagents.defaults.*changes - Entrypoint isn't a binary -- always use
node dist/index.js
The most important lesson: Don't over-engineer. Start with the official image and minimal config, verify each step works, then incrementally add features. Trying to set up all configs, toolchains, and automation at once only makes troubleshooting harder.