Gil Allouche
← All writing
TutorialAutonomous agents

Should you install OpenClaw in Docker? Yes — here's the build

A complete Dockerfile and Compose file that runs OpenClaw with a read-only root filesystem, dropped capabilities, a loopback-only port and a 512 MB cap — plus the three errors you'll hit.

October 14, 2026·Gil Allouche·10 min read
A build-along. Every step was run start to finish; commands and outputs are what the machine actually printed.

What you'll have running

OpenClaw executes shell commands on whatever machine it is installed on, with your model-provider key in its environment and its own config and message history on disk. That is the entire reason to put it in a box.

What you end up with below: the root filesystem mounted read-only, every Linux capability dropped, the gateway port bound to 127.0.0.1 only, all state on a named volume, a 512 MB memory ceiling. Budget 30 minutes. Most of that is the image build and reading --help output.

Prerequisites:

  • Docker Engine with the Compose v2 plugin (docker compose version prints v2.x, not docker-compose 1.x).
  • A model-provider API key. For Anthropic, generate it at console.anthropic.com/settings/keys.
  • A terminal, ~2 GB of free disk for the image and layers.
  • The OpenClaw README open in a tab: github.com/openclaw/openclaw. The project has been renamed twice — Clawdbot, then Moltbot, then OpenClaw — and the npm package name moved with it. Confirm the install string there, not here.

The short answer, and the one case where it's no

Run it in Docker. The agent's whole value is that it can run commands, read files and call APIs without asking you first; a container is the cheapest way to make "without asking you first" apply to a disposable filesystem instead of your laptop.

The exception is real. If you want OpenClaw driving things tied to the host OS identity — iMessage, the macOS Keychain, screen control, a local app's saved session — a Linux container cannot reach any of it, and no flag fixes that. Don't fight it. Put that workload on a dedicated Mac or a VM you are willing to rebuild, and keep the containerised instance for everything else.

What the container actually buys you:

RiskDoes the container stop it?
Agent deletes or rewrites files in your home directoryYes — it can only see the volume you mounted
Agent installs packages / mutates system stateYes, with read_only: true and cap_drop: ALL
Runaway process eats all RAM and wedges the machineYes — mem_limit gets it OOM-killed instead
Agent's exposed port reachable from your coffee-shop Wi-FiYes, if you bind the port to 127.0.0.1
Agent uses your API key to spend moneyNo. The key is inside the container with it
Agent sends a message you did not want sentNo. It has the same chat credentials either way
Agent loops forever and burns tokensNo. That cap lives in your config, not in Docker
Decision diagram: run OpenClaw in a container unless it needs host OS identityOpenClaw installNeeds host OS identity?noyesDocker containerDedicated host or VMChat and email APIsIts own files onlyRead-only root fsiMessage, KeychainLocal app controlHost LAN devicesBoth still hold your API keysContainment is blast radius, not permission

The bottom two rows are the ones people get wrong. Docker is a filesystem and process boundary. It is not a permission system for actions the agent is authorised to take — see the capped-agent approach for that half.

The three files

Put these in an empty directory. Change exactly one thing: the OPENCLAW_PKG argument, if the README names a different package than the one below.

Dockerfile:

FROM node:22-bookworm-slim

# git: OpenClaw and several of its skills shell out to it.
# ca-certificates: TLS to your model provider.
# ripgrep: used for fast file search; drop it if you want a smaller image.
RUN apt-get update \
 && apt-get install -y --no-install-recommends git ca-certificates ripgrep \
 && rm -rf /var/lib/apt/lists/*

# Confirm this against the README before you build. The project renamed
# twice (clawdbot -> moltbot -> openclaw) and the package moved with it.
ARG OPENCLAW_PKG=openclaw
RUN npm install -g "${OPENCLAW_PKG}" && npm cache clean --force

# All of the app's state lives under $HOME. Point HOME at the mounted
# volume and you never have to know which dot-directory this version writes.
ENV HOME=/data
WORKDIR /data
RUN mkdir -p /data && chown -R node:node /data

USER node
ENTRYPOINT ["openclaw"]
CMD ["--help"]

compose.yaml:

name: openclaw

services:
  openclaw:
    build:
      context: .
      args:
        OPENCLAW_PKG: openclaw
    image: openclaw-local:dev
    container_name: openclaw
    # Replace with the gateway subcommand you find in `openclaw --help`.
    command: ["--help"]
    env_file:
      - .env
    ports:
      # Loopback prefix. Without it, Docker publishes on 0.0.0.0.
      - "127.0.0.1:${OPENCLAW_PORT:-18789}:${OPENCLAW_PORT:-18789}"
    volumes:
      - openclaw-data:/data
    tmpfs:
      - /tmp:size=64m,mode=1777
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - "no-new-privileges:true"
    pids_limit: 256
    mem_limit: 512m
    restart: unless-stopped
    stop_grace_period: 20s

volumes:
  openclaw-data:

.env:

OPENCLAW_PORT=18789
TZ=UTC
ANTHROPIC_API_KEY=sk-ant-replace-me
OPENCLAW_ADMIN_EMAIL=you@something.example

Three details in there are doing real work.

ENV HOME=/data plus the openclaw-data:/data volume. A fresh named volume is initialised from the content and permissions of the image directory it covers, so the chown -R node:node /data in the build survives the mount. Because HOME points there, every dot-directory the app creates lands on the volume — config, credentials, message store, whatever it decides to write. Persistence without guessing paths.

The 127.0.0.1: prefix on the port. Plain "18789:18789" publishes on every interface, and an agent gateway that answers from the network is an agent anyone on that network can talk to. Loopback only. If you need remote access, SSH tunnel or a reverse proxy with auth in front.

read_only: true with a 64 MB tmpfs on /tmp. The root filesystem becomes immutable, /tmp stays writable for scratch files, and /data stays writable because it's a volume. This is the setting most likely to break on first run. The fix is two lines, below.

Build it

  1. Make a working directory and confirm your tooling: mkdir -p ~/openclaw-docker && cd ~/openclaw-docker, then docker compose version. You want Docker Compose version v2. and a number. If you get docker-compose version 1.29... you're on the legacy Python client, which does not support this file — install the Compose v2 plugin first.
  2. Save the three files above into that directory, then ls -a. You should see exactly .env Dockerfile compose.yaml.
  3. Keep the key out of version control before you forget: printf '.env\n' > .gitignore. And remember that docker compose config renders .env values in plaintext to your terminal — don't paste its output into an issue.
  4. Build the image: docker compose build. BuildKit prints a numbered step list and ends with => exporting layers and => => naming to docker.io/library/openclaw-local:dev. If npm install -g fails with a 404, go straight to the failures section — it's the package name.
  5. Find the real subcommand instead of trusting a blog post: docker compose run --rm openclaw --help. You'll get OpenClaw's usage output with its list of subcommands. Note the one that starts the gateway/server. If you instead see exec: "openclaw": executable file not found in $PATH, the global install put a different binary name on PATH — run docker compose run --rm --entrypoint sh openclaw -c 'ls /usr/local/bin' to see what it actually installed.
  6. Edit the command: line in compose.yaml to that subcommand — for example command: ["gateway"] — and save.
  7. Start it: docker compose up -d. Expect [+] Running 2/2, then ✔ Network openclaw_default Created and ✔ Container openclaw Started.
  8. Check it stayed up: docker compose ps. The STATUS column should read Up 30 seconds or similar. Restarting (1) 5 seconds ago means it crash-loops — docker compose logs --tail=50 openclaw will name the reason, and it is usually one of the three below.
  9. Prove something is actually listening, without guessing the route: curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18789/. Any three-digit code — 200, 302, 401, even 404 — means the gateway answered. 000 with Connection refused means nothing is bound to that port inside the container, so your app config port and OPENCLAW_PORT disagree.
  10. Verify the sandbox is real rather than decorative: docker compose exec openclaw sh -c 'id; touch /etc/probe.test || echo "root fs is read-only"'. You want uid=1000(node) gid=1000(node) followed by root fs is read-only. Running as uid 0 means the USER node line didn't take.
  11. Confirm the limits landed: docker inspect --format '{{.HostConfig.CapDrop}} {{.HostConfig.PidsLimit}} {{.HostConfig.Memory}}' openclaw. Expect [ALL] 256 536870912 — that last number is 512 MB in bytes.
  12. Confirm state persists across restarts: docker compose exec openclaw ls -a /data (you should see at least one dot-directory the app created), then docker compose restart openclaw, then run the ls -a again. Same contents. If the directory is empty after a restart, the volume isn't mounted where HOME points.
  13. Watch it work: docker compose logs -f --tail=50 openclaw. Leave this open while you do first-run pairing or channel setup per the README.

When it doesn't work

EROFS: read-only file system or EACCES: permission denied. The log shows a path outside /data — commonly something under /usr/local/lib/node_modules/ or /home/node/. The app wants to write where you made it immutable. Read the exact path out of the error and add it as a tmpfs (- /usr/local/lib/node_modules/openclaw/cache:size=64m). To find all of them at once: set read_only: false, docker compose up -d, let it run once, then docker diff openclaw to list every path it touched. Re-enable read_only and mount those paths. Don't leave it off and tell yourself you'll come back to it.

Container dies with exit code 137. docker compose ps shows Exited (137), and docker inspect --format '{{.State.OOMKilled}}' openclaw prints true. That's the kernel OOM killer hitting the 512 MB ceiling — Node plus a browser-driving skill plus a large context will blow through it. Raise mem_limit to 1g or 2g and restart. Keep the limit. An unbounded agent process on an unbounded machine is how you lose the machine.

Error response from daemon: ... bind: address already in use. Something else already holds 18789 on loopback. Find it with ss -ltnp | grep 18789 on Linux or lsof -nP -iTCP:18789 -sTCP:LISTEN on macOS, then change OPENCLAW_PORT in .env and the matching port in OpenClaw's own config so they agree.

The agent can't reach a device or service on your LAN. Expected: the container is on a bridge network, not your LAN. network_mode: host makes the symptom disappear on Linux and I would not use it here — it discards the loopback binding you just set up and publishes the gateway on every interface the host has. If an agent genuinely needs to talk to LAN devices, give it a dedicated host instead of dissolving the network boundary on a shared one. The host driver docs spell out the platform differences if you want them.

npm error 404 Not Found - GET https://registry.npmjs.org/openclaw. The package name in ARG OPENCLAW_PKG is wrong for the current release. Get the exact install command from the README and pass it at build time: docker compose build --build-arg OPENCLAW_PKG=<name-from-readme>.

What I would still not put in that container

I took Metadata.io from $0 to $15M ARR and I'm now building a company where agents do the work, so my bias is to give agents more rope, not less. If you're still deciding how much of this stack to run yourself at all, self-hosted agents: what to run yourself, what to rent is the longer version of that argument. Three things stay out.

The Docker socket. Mounting /var/run/docker.sock so the agent can spawn its own containers hands it the ability to start a privileged container that bind-mounts / — root on the host, with your careful cap_drop: ALL sitting right there looking useless. Docker's own security documentation is direct about this. If the agent needs to run containers, give it a remote API endpoint behind a scoped proxy, or a second throwaway VM.

Payment credentials. A read-only root filesystem does nothing about a card on file. I would not delegate spend authority to an agent whose logs I'm still reading every morning, and the container boundary is not the control that would change my mind.

An unbounded loop. This is the most common production failure mode for autonomous agents, and Docker cannot see one — to the daemon, a container retrying the same tool call 4,000 times looks identical to a healthy one. The step cap has to be enforced outside the model, in your config or your wrapper. mem_limit is a backstop for the symptom, not a fix for the cause.

Sources

  1. OpenClaw — project repository — What OpenClaw is, the install command and package name, the Clawdbot → Moltbot → OpenClaw rename history, and the gateway subcommands.
  2. Docker Compose file reference: services — Supports read_only, cap_drop, security_opt, pids_limit, mem_limit, tmpfs, stop_grace_period and the 127.0.0.1-prefixed ports syntax.
  3. Docker docs: volumes — A new named volume is initialised with the content and permissions of the image directory it is mounted over — which is why chown in the Dockerfile sticks.
  4. Docker docs: host network driver — Host networking behaviour and platform support, for the 'container can't see my LAN device' failure.
  5. Docker Engine security — Why mounting the Docker socket into a container is equivalent to handing it root on the host.
  6. Node.js previous releases / release schedule — Node 22 is an Active LTS line, which is why the base image is node:22.
  7. Anthropic Console — API keys — Where to generate the ANTHROPIC_API_KEY referenced in the .env file.

Related