Searching for “How to set up OpenClaw with Docker isolation for security” usually turns up a mix of outdated Dockerfiles or hand-wavy blog posts. I wanted a single, opinionated recipe that I would actually trust in production. After a week of poking at namespaces, seccomp, and broken permissions, this is the working setup I now run on my own Kubernetes cluster and my M2 MacBook.
Everything below was tested with Docker 26.1, OpenClaw v0.18.4, Node.js 22.2, and the Debian bookworm-slim base image. Feel free to swap images, but be prepared to tweak UID mappings and package installs.
Why run OpenClaw in Docker at all?
OpenClaw’s agent framework can already sandbox individual tools, but the process itself still has access to your entire user account. A compromised browser automation script could read your dotfiles, steal SSH keys, or hit internal services that your laptop VPN exposes. Docker buys us three things:
- Filesystem namespaces – container sees only what you mount.
- Network control – no surprise egress to
198.51.100.7. - Resource caps – runaway fine-tuned LLM can’t eat 32 GB RAM.
The cost is extra complexity and, on macOS, a small performance penalty because Docker Desktop still runs a hidden VM. Also, GUI integrations (e.g. macOS iMessage control) are impossible because containers can’t touch native Cocoa APIs. If your agent needs that, stop reading now.
Prerequisites and threat model
The recipe assumes you want to:
- Run OpenClaw gateway + daemon as one container.
- Expose only port 3000 on localhost.
- Persist agent memory, logs, and auth secrets to the host, but nothing else.
- Block all outgoing traffic except to the OpenAI API and Slack webhook (adjust as needed).
Install the basics:
# Ubuntu / Debian host packages
sudo apt-get update && sudo apt-get install -y docker-ce docker-compose-plugin
# Confirm versions
docker --version # Docker version 26.1.3
docker compose version # v2.27.0
I run everything as an unprivileged user. If you’re still adding yourself to docker group, reconsider. Better to keep sudo and use sudoedit /etc/sudoers.d/99-docker-nopasswd so only the commands you whitelist are passwordless.
Building a minimal Dockerfile for OpenClaw
90 % of the “official” Dockerfiles I found use node:latest, run as root, and install git, curl, and vim for good measure. Let’s keep it boring and tight:
# Dockerfile
FROM debian:bookworm-slim AS base
ENV NODE_VERSION 22.2.0
ENV OPENCLAW_VERSION 0.18.4
ENV HOME /home/app
# 1️⃣ Install system deps (only what node needs)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget dumb-init tini \
&& rm -rf /var/lib/apt/lists/*
# 2️⃣ Create non-root user
RUN useradd -m -u 10001 -s /usr/sbin/nologin app
WORKDIR /home/app
USER app
# 3️⃣ Install Node.js by hand (no nvm, no curl | bash)
RUN wget -q https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz \
&& tar -xf node-v$NODE_VERSION-linux-x64.tar.xz --strip-components=1 -C /usr/local \
&& rm node-v$NODE_VERSION-linux-x64.tar.xz
# 4️⃣ Install OpenClaw globally, yarn not needed
RUN npm --prefix /usr/local install -g openclaw@$OPENCLAW_VERSION --omit=dev --no-audit --no-fund
# 5️⃣ Copy an entrypoint script (see below)
COPY --chown=app:app docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/bin/dumb-init", "--", "docker-entrypoint.sh"]
Notes:
USER appensures the process runs under UID 10001. We can map that to a host UID later if needed.dumb-inithandles PID 1 signal reaping;tiniworks too.- No compiler toolchain, so
npm install canvaswill fail—good. That stops random agents from pulling in native modules at runtime.
The docker-entrypoint.sh keeps configuration in env vars so we don’t have to bake API keys into the image:
#!/usr/bin/env sh
set -euo pipefail
# Fail fast if required vars are missing
: "${OPENCLAW_LICENSE:=}"
: "${OPENAI_API_KEY:=}"
exec openclaw gateway --port 3000 --memory /data/memory.json
Mark it executable: chmod +x docker-entrypoint.sh.
Volume mounts: give the container only what it needs
OpenClaw stores persistent state in three places:
- Agent memory (JSON) – default
~/.openclaw/memory.json - Logs – rotating
~/Library/Logs/openclawon macOS,~/.local/share/openclaw/logson Linux - User-level config like
openclaw.yaml
I consolidate all of that into one host directory ./claw-data and mount it read-write. Everything else is read-only or a tmpfs.
# docker-compose.yml
services:
openclaw:
build: .
image: openclaw-secure:0.18.4
container_name: openclaw
user: "10001:10001"
environment:
- OPENAI_API_KEY
- OPENCLAW_LICENSE
volumes:
- type: bind
source: ./claw-data
target: /data
read_only: false
- type: tmpfs
target: /tmp
- type: bind
source: /etc/localtime
target: /etc/localtime
read_only: true
ports:
- "127.0.0.1:3000:3000"
Why bind /etc/localtime? Otherwise the container uses UTC, and timestamp math in the scheduler can drift. Read-only bind prevents tampering.
No source code directory is mounted. If you write custom tools, you should publish them as npm packages or build another image layer rather than hot-mounting the repo—hot mounts break the sandbox.
Network isolation: egress filter and no incoming surprises
Out of the box, Docker puts every container on docker0 with NAT. That’s fine until an agent decides to port-scan your private RFC 1918 network to find redis. We can tighten things.
Option A: Docker compose network none
network_mode: none
This removes network entirely. If your agent must talk to an LLM or Slack, this won’t work. Good for read-only experimenting though.
Option B: Custom user-defined network + egress ACL
Docker itself lacks fine-grained egress rules, but we can attach a dedicated network and then use the host firewall (nftables, pf, or Little Snitch) to allow only the required domains. Example on Linux using nft:
# Create a table and chain just for Docker egress
sudo nft add table inet dockerout
sudo nft add chain inet dockerout out { type filter hook postrouting priority 0 \; }
# Allow Slack webhook
sudo nft add rule inet dockerout out ip daddr 3.228.0.0/14 tcp dport 443 accept
# Allow OpenAI traffic
sudo nft add rule inet dockerout out ip daddr 104.16.0.0/13 tcp dport 443 accept
# Default drop for the container subnet 172.18.0.0/16
sudo nft add rule inet dockerout out ip saddr 172.18.0.0/16 drop
Yes, IP ranges are brittle. If you need something less hacky, run OpenClaw behind a local proxy (e.g. tinyproxy) and allowlist only that.
For incoming traffic, binding to 127.0.0.1 in compose already blocks LAN access. If you want remote access, put Nginx with mTLS in front of the container—don’t expose the port directly.
Syscall and capability lockdown
Docker ships with a default seccomp profile that blocks mount, reboot, etc. Good start, but OpenClaw doesn’t need most of the other 300 syscalls either.
# Allow only what Node needs (generated with the excellent oci-seccomp-bpf-hook)
security_opt:
- seccomp:./seccomp-node.json
cap_drop:
- ALL
read_only: true
The read_only: true flag turns the entire root filesystem immutable. We already mounted /data and /tmp as writeable, so OpenClaw still works. If some npm code tries to self-update globally, it’ll fail—exactly what we want.
Resource limits: memory, CPU, GPU
Large language models tend to balloon. Even though the agent runs mostly network I/O, some embeddings or fine-tuned local models can surprise you.
deploy:
resources:
limits:
cpus: "2.0" # two cores max
memory: 2g # kill at 2 GB
reservations:
cpus: "1.0"
memory: 256m
Docker will send SIGKILL if the container hits the limit. OpenClaw’s gateway survives abrupt restarts well thanks to persistent memory.
GPU: by design I omit --gpus. If you insist on local inference, you’ll have to loosen the sandbox because the NVIDIA container runtime pulls in capabilities=SYS_ADMIN. Trade-off: more speed vs. a larger attack surface. For 90 % of Slack/Discord agents that call OpenAI, CPU is fine.
Running and updating the container
Spin it up:
# First run creates claw-data directory with correct UID 10001
docker compose --profile prod up -d --build
# Live logs
docker logs -f openclaw
On upgrade day:
- Change
OPENCLAW_VERSIONARG in the Dockerfile. docker compose pull(or rebuild).docker compose up -d --force-recreate.
The immutable root means nothing from the old container lingers. If something goes wrong:
docker compose down && git checkout HEAD~1 Dockerfile && docker compose up -d --build
Rollback in 30 seconds.
Automated security scanning
I run Trivy in CI so PRs can’t merge if the base image gains a critical CVE:
# .github/workflows/scan.yml
jobs:
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: docker build -t openclaw-secure:ci .
- name: Trivy
uses: aquasecurity/trivy-action@v0.14.0
with:
image-ref: openclaw-secure:ci
ignore-unfixed: true
exit-code: 1
This has already caught one high-severity glibc issue before my weekend deploy. Worth the 30 seconds.
What breaks when you sandbox this hard?
- shell tool – OpenClaw’s
shell.execintegration will fail becausecap_drop: ALLremovesCAP_SETUID. If you need OS-level shell, consider a second, less-locked container connected via gRPC. - Browser control – Playwright/Chrome inside the same container wants shared memory and SYS_PTRACE. Either loosen seccomp or spin up a sibling “browser” container with its own sandbox.
- macOS-only features – iMessage, Calendar AppleScript, etc. are off the table. Containers can’t talk to GUI apps without extra entitlement voodoo.
I accept these trade-offs for anything I expose on the public internet. Internal hobby projects get a more permissive compose file.
Practical takeaway
Docker doesn’t magically secure OpenClaw. You have to:
- Run as non-root, immutable rootfs.
- Mount only
/datawriteable. - Drop all capabilities and apply a trimmed seccomp profile.
- Firewall egress to the bare minimum.
- Cap CPU/RAM to prevent accidental DoS on your own machine.
The full repo with the Dockerfile, compose, and seccomp profile lives at github.com/yourhandle/openclaw-docker-secure. Clone it, swap in your API keys, and you’ll have a hardened agent running in under ten minutes. When someone inevitably pastes a sketchy prompt into your Slack bot, you’ll sleep better knowing the worst it can do is corrupt its own memory.json.