OpenClaw with Docker: install and harden the gateway

OpenClaw runs as a permanent gateway, and tens of thousands of instances are open on the Internet. Here's the hardened Docker Compose setup, run and measured on 7 September 2026.

OpenClaw with Docker: install and harden the gateway
Quick answer

OpenClaw is an open source personal assistant that runs as a permanent gateway, listens on port 18789, and runs skills. Under Docker, use the official ghcr.io/openclaw/openclaw image, publish the port on 127.0.0.1 only, drop all capabilities from the container, and keep the token in an .env file outside the compose. Watch out: the gateway refuses to start until gateway.mode is written, and publishing on the loopback doesn't protect it from other containers on the machine.

OpenClaw installs in one command, and that’s exactly the problem: on 11 February 2026, SecurityScorecard counted more than forty thousand gateways open on the Internet within the first twenty-four hours of its scan, 35.4% of them judged vulnerable to remote code execution. Docker doesn’t fix anything on its own, what matters is what your compose file publishes, what it strips from the container, and where the secrets live.

What is OpenClaw?

OpenClaw is an open source personal assistant, under the MIT licence, previously known as Clawdbot, then Moltbot. It isn’t a coding agent in the sense of Claude Code or Codex, but a gateway, a process that runs permanently, exposes a web interface (the Control UI) and a WebSocket API on port 18789, wires up messaging channels (WhatsApp, Telegram, Discord), and runs “skills” on its owner’s behalf.

The language model comes from elsewhere: the official image bundles plugins for Anthropic, OpenAI, xAI and Ollama, and you supply the key or the local server’s address. The gateway is therefore a machine for running instructions, the model being just one of its providers. That’s what makes its network exposure dangerous, far more than whichever model you pick.

Why Docker rather than a local install?

The documentation says Docker is optional, and it’s right on the functional level. On the security level, the difference is stark. A local install hands the gateway your user account: your home folder, your SSH keys, your keychain, your repos. A container gives it an unprivileged user, three volumes, and nothing else. It’s the same reasoning as for coding agents, detailed in sandboxing Claude Code and Codex: the only boundary that holds is the one the operating system enforces.

The price is real. The system dependencies some skills need (ffmpeg, tmux, a browser) aren’t in the image, and the documentation is categorical: installing binaries into a running container is a trap, you have to bake them in at build time with OPENCLAW_IMAGE_APT_PACKAGES.

Which image should you pick?

OpenClaw’s Docker documentation lists two repos. The official registry is ghcr.io/openclaw/openclaw, with a Docker Hub mirror under openclaw/openclaw. A third one, alpine/openclaw, is an unofficial mirror the documentation explicitly asks you to avoid, because it follows neither the project’s release schedule nor its retention policy.

I pulled both on 7 September 2026 to check the gap.

bash
docker pull ghcr.io/openclaw/openclaw:latest   # 2 min 30 s
docker pull alpine/openclaw:latest             # 1 min 28 s

docker run --rm ghcr.io/openclaw/openclaw:latest openclaw --version
# OpenClaw 2026.9.2 (3928bad)

docker run --rm alpine/openclaw:latest openclaw --version
# OpenClaw 2026.6.9

docker image inspect ghcr.io/openclaw/openclaw:latest \
  --format '{{index .Config.Labels "org.opencontainers.image.created"}}'
# 2026-09-05T15:21:16.692Z

The alpine/openclaw mirror was frozen at version 2026.6.9, built on 21 June 2026: nearly three months behind, Debian security patches included. The official image, meanwhile, was at version 2026.9.2, built on 5 September, two days before my test. This isn’t a matter of taste.

Budget the space: docker image inspect reports 1.10 GB of content for the official image, but the DISK USAGE column of docker images under Docker 29 counted 4.43 GB actually taken up on disk.

The hardened compose file

The documentation provides a full docker-compose.yml, but it assumes a cloned repo and an install script. Here’s the minimal version I wrote and ran, with the pre-built image only.

yaml
name: openclaw-lab

x-openclaw-env: &openclaw-env
  HOME: /home/node
  OPENCLAW_HOME: /home/node
  OPENCLAW_STATE_DIR: /home/node/.openclaw
  OPENCLAW_CONFIG_DIR: /home/node/.openclaw
  OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
  OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace
  OPENCLAW_GATEWAY_PORT: "18789"
  OPENCLAW_DISABLE_BONJOUR: "1"
  TZ: Europe/Paris

services:
  gateway:
    image: ghcr.io/openclaw/openclaw:2026.9.2
    container_name: openclaw-lab-gateway
    init: true
    restart: unless-stopped
    env_file:
      - path: .env          # the token lives here, not in the compose file
        required: true
    environment: *openclaw-env
    command: ["node", "openclaw.mjs", "gateway", "--bind", "lan", "--port", "18789"]
    ports:
      - "127.0.0.1:18789:18789"
    volumes:
      - state:/home/node/.openclaw
      - workspace:/home/node/.openclaw/workspace
      - authsecrets:/home/node/.config/openclaw
    networks: [openclaw]
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: ["CMD", "node", "dist/docker-healthcheck.js"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s

  cli:
    image: ghcr.io/openclaw/openclaw:2026.9.2
    profiles: ["cli"]
    network_mode: "service:gateway"
    init: true
    env_file:
      - path: .env
        required: true
    environment: *openclaw-env
    entrypoint: ["node", "openclaw.mjs"]
    volumes:
      - state:/home/node/.openclaw
      - workspace:/home/node/.openclaw/workspace
      - authsecrets:/home/node/.config/openclaw
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    depends_on: [gateway]

networks:
  openclaw:
    name: openclaw-lab

volumes:
  state:
  workspace:
  authsecrets:

Six decisions deserve an explanation.

  • The 127.0.0.1: prefix in ports: without it, Docker publishes on every interface on the machine, including the local network one. It’s the most common mistake, and it accounts for a good share of the gateways found by scanners.
  • --bind lan regardless: inside the container, loopback would mean “nobody can reach me, not even Docker.” It’s publishing the port on the host’s 127.0.0.1 that does the restricting, not the internal binding mode.
  • cap_drop: [ALL]: the official compose only drops NET_RAW and NET_ADMIN. Dropping everything works too, as checked further down.
  • The cli service behind a profiles flag: it shares the gateway’s network stack (network_mode: "service:gateway"), so it sits inside the trust boundary. The profile keeps an absent-minded docker compose up from leaving it running permanently.
  • Named volumes, not bind mounts: the documentation insists on mounting state as a directory, never as a lone file, or risk a mismatch between host and container after a configuration write.
  • env_file rather than environment: the token appears in neither the compose file nor the Git repo.

One note on the port: 18789 was already taken on my machine by another gateway, so I published the lab on 127.0.0.1:18889. Every measurement below is on this port, swap it for 18789 on your own machine, including in gateway.controlUi.allowedOrigins.

The .env file is generated locally and never gets committed.

bash
umask 077
printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "$(openssl rand -hex 32)" > .env
chmod 600 .env
echo '.env' >> .gitignore

First start: the missing configuration

Launching the stack as-is isn’t enough. The container starts, fails, restarts, and starts over.

bash
docker compose up -d gateway
docker compose logs gateway | tail -3
# [gateway] loading configuration…
# [gateway] resolving authentication…
# Missing config. Run `openclaw setup` or set gateway.mode=local (or pass --allow-unconfigured).

With restart: unless-stopped, Docker was already at eleven restarts by the time I checked the logs, and the container was marked unhealthy. The gateway refuses to serve with no explicit configuration: that’s a sound default, but you need to know about it. The fix requires no provider key at all.

bash
docker compose stop gateway

docker compose run --rm -T --no-deps --entrypoint node gateway openclaw.mjs \
  config set --batch-json '[
    {"path":"gateway.mode","value":"local"},
    {"path":"gateway.bind","value":"lan"},
    {"path":"gateway.auth.mode","value":"token"},
    {"path":"gateway.controlUi.allowedOrigins","value":["http://127.0.0.1:18789"]}
  ]'
# Updated 4 config paths. Restart the gateway to apply.

docker compose up -d --force-recreate gateway

The --no-deps --entrypoint node isn’t decorative: the cli service shares the gateway’s network stack, so it only works once the gateway container has been created. To write the configuration before the first start, you have to go through the gateway’s own image.

The timed result: /healthz answered 200 in 7.6 seconds, and Docker marked the container healthy at 11.9 seconds. A warm restart, with the state already initialised, drops to 5.0 seconds.

bash
curl -s http://127.0.0.1:18789/healthz   # {"ok":true,"status":"live"}
curl -s http://127.0.0.1:18789/startupz  # {"ok":true,"status":"started"}
curl -s http://127.0.0.1:18789/readyz    # {"ready":true}

The startup logs are chatty, and instructive.

bash
[gateway] ⚠️  Gateway is binding to a non-loopback address. Ensure authentication
          is configured before exposing to public networks.
[gateway] agent model: openai/gpt-5.6-sol (thinking=medium, fast=off)
[gateway] http server listening (13 plugins: anthropic, browser, canvas,
          cua-computer, device-pair, file-transfer, geolocation, linux-node,
          memory-core, ollama, openai, talk-voice, xai; 2.7s)
[gateway] log file: /tmp/openclaw/openclaw-2026-09-07.log
[gateway] remote model catalog updated; restart the Gateway to apply it

Notice the last line: with no key configured at all, the gateway still went and fetched a model catalogue from the Internet. A gateway “at rest” still reaches out over the network.

The configuration file it writes is 348 bytes and holds no secret: the token stays in the environment variable.

json
{
  "gateway": {
    "mode": "local",
    "bind": "lan",
    "auth": { "mode": "token" },
    "controlUi": { "allowedOrigins": ["http://127.0.0.1:18789"] }
  },
  "meta": { "lastTouchedVersion": "2026.9.2" }
}

Alongside it, state/openclaw.sqlite already weighed 1.5 MB. That’s where OAuth tokens end up, in plain text: the documentation asks you to treat this directory and its backups as credentials.

What the hardening actually blocks

Three checks beat one statement of intent.

Capabilities and the user

bash
docker compose exec -T gateway sh -lc \
  'grep -E "^Cap(Prm|Eff|Bnd)" /proc/1/status; id; grep NoNewPrivs /proc/self/status'
# CapPrm: 0000000000000000
# CapEff: 0000000000000000
# CapBnd: 0000000000000000
# uid=1000(node) gid=1000(node) groups=1000(node)
# NoNewPrivs: 1

No capabilities, an unprivileged user, no possible escalation. The official image already does half the work: it runs as node (uid 1000) and launches tini as process 1.

A port published on the loopback doesn’t protect you from Docker

This is the result that surprised me the most. The port is only published on 127.0.0.1, yet any container at all, even on a different Docker network, can still reach the gateway through its bridge address.

bash
GWIP=$(docker inspect -f \
  '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' openclaw-lab-gateway)
echo "$GWIP"   # 172.29.0.2

# from the default network, not the lab's
docker run --rm alpine:3.22 sh -lc \
  "apk add --no-cache curl >/dev/null; \
   curl -s -o /dev/null -w '%{http_code}\n' http://$GWIP:18789/healthz"
# 200

The 127.0.0.1: prefix closes off the machine’s interfaces, not the Docker daemon’s network. If you host other containers on the same machine, just one of them getting compromised is enough to talk to the gateway. The fix is the gateway’s own authentication and, on a Linux server, rules in the DOCKER-USER chain: OpenClaw’s hardening documentation gives a full set for UFW, because regular INPUT rules never see traffic published by Docker.

The internal network costs more than it looks

The next instinct is to switch the network to internal: true. I measured what that takes away.

yaml
# docker-compose.internal.yml
networks:
  openclaw:
    internal: true
Check Bridge network internal network
fetch('https://api.anthropic.com/') from the container HTTP 404 (reachable) fetch failed
curl http://127.0.0.1:18789/healthz from the host HTTP 200 connection refused
host.docker.internal resolves yes yes
Host service reached via host.docker.internal HTTP 200 fetch failed
Container marked healthy yes yes

Two consequences are counter-intuitive. First, Docker Compose silently ignores the ports section on an internal network: docker compose ps shows 18789/tcp instead of 127.0.0.1:18789->18789/tcp, with no warning at all, and the Control UI becomes unreachable. Second, host.docker.internal keeps resolving but stops routing: an Ollama running on the host machine isn’t reachable either.

The internal network is only usable, then, for a gateway driven entirely by docker compose exec, with a model served by another container on the same network. For everything else, the right answer is a dedicated bridge plus an SSH tunnel: OpenClaw’s documentation actually recommends Tailscale Serve over a LAN connection.

Connecting a model without writing a key into the compose file

Three paths exist, and none of them requires pasting a key into a versioned file.

  1. A remote provider: the key goes into .env (ANTHROPIC_API_KEY, OPENAI_API_KEY), read via env_file. It never enters openclaw.json.
  2. A local model on the host machine: inside a container, 127.0.0.1 refers to the container itself. The documentation requires http://host.docker.internal:11434 for Ollama, and the host has to listen beyond its own loopback (OLLAMA_HOST=0.0.0.0:11434 ollama serve).
  3. The Claude Code binary inside the container: possible, but you have to persist /home/node in a named volume, otherwise the next image update wipes out the install and the authentication.

No key was ever entered during this test, and the ollama plugin is indeed loaded by the gateway. The local-model path can be checked without one: with an Ollama published on port 11434 of the host, http://host.docker.internal:11434/v1 answers 200 in OpenAI format from a container on the bridge network. That’s exactly the address the documentation requires, and the reason 127.0.0.1 wouldn’t work: inside a container, it points at the container.

Updating the image without breaking the state

The moving tags (latest, main, extended-stable) get rebuilt every week from the same source, to pick up base-system security patches between two OpenClaw versions. Every rebuild also publishes an immutable dated tag, of the form 2026.8.1-r20260820: that’s the one to pin when you don’t want a deployment following a moving tag.

When the image changes, the gateway applies its migrations on startup. If it can’t, it exits with an error rather than declaring itself healthy, and with a restart policy, you’ll see a loop. The documented fix is to run the same image once more with openclaw doctor --fix against the same volumes, then restart normally.

bash
docker compose pull
docker run --rm -v openclaw-lab_state:/home/node/.openclaw \
  ghcr.io/openclaw/openclaw:2026.9.2 openclaw doctor --fix
docker compose up -d gateway

Choosing skills on ClawHub

The image ships 53 ready-to-use skills: 51 in the base package, 2 as extras. That’s already a lot, and it covers most uses. The rest comes from ClawHub, the public registry, and that’s where the trouble starts. The format is the same SKILL.md described in our Agent Skills guide: a YAML frontmatter, a Markdown body, accompanying files.

The skills documentation doesn’t pull its punches: it asks you to treat every third-party skill as untrusted code and to read it before activating it. The numbers back it up. A survey published on 1 March 2026 credits Antiy CERT with 1,184 confirmed malicious skills on ClawHub, around one package in five at the peak of the campaign.

skills verify queries the registry without installing anything, which is welcome. Its overall verdict, though, is worth opening up. Here’s what it returns for a popular Docker skill, as of the test date.

bash
docker compose run --rm -T cli skills verify @ivangdavila/docker

# decision            : pass
# security.status     : clean
# security.verdict    : benign      (confidence: high)
# signature.status    : unsigned
# provenance.source   : unavailable
# signals.staticScan  : suspicious  -> suspicious.exposed_secret_literal
# signals.skillSpector: suspicious
# signals.virusTotal  : clean
# artifact.files      : 16 files, including SKILL.md (24,283 bytes)

Two of the three signals say “suspicious,” the package isn’t signed, its GitHub provenance isn’t recorded, and the aggregate verdict still reads “benign, high confidence.” The readable summary talks about a local assistant with no exfiltration detected, and it’s not this particular skill that’s at fault. Hold on to the gap between the signals and the conclusion: a green score isn’t the same as actually reading it.

The rule I’m taking away comes down to three points: read the SKILL.md before installing (skills info gives the file’s exact path), refuse any skill that reaches out over the network without its function requiring it, and prefer the 53 skills shipped with the image for as long as they’re enough. The configuration’s security.installPolicy field lets you enforce this safeguard instead of relying on discipline.

Adding MCP servers

The gateway manages its MCP servers under mcp.servers, with a full command surface: add (which probes the server before registering it), probe, doctor, status, tools to filter the exposed tools, and login / logout for OAuth servers.

bash
docker compose run --rm -T cli mcp doctor     # static configuration flaws
docker compose run --rm -T cli mcp status     # transports, without connecting
docker compose run --rm -T cli mcp probe      # a real connection, lists the capabilities
docker compose run --rm -T cli mcp tools      # include/exclude filters per server

Two habits are worth keeping. mcp tools exists, use it. An MCP server often exposes thirty tools when you only want three, and every extra tool is a description the model reads as an instruction. And an MCP server over STDIO runs inside the gateway’s own container, so with its volumes and its environment variables. The Cloud Security Alliance’s research note from 4 May 2026 recommends exactly the opposite: a dedicated container per server, with no access to the host’s credentials. The subject is covered in depth in building an MCP server in PHP.

OpenClaw, Claude Code, or Hermes Agent?

The three don’t play in the same space, and mixing them up leads to bad trade-offs.

OpenClaw Claude Code Hermes Agent
Form Permanent gateway Terminal session Containerised agent
Trigger Messaging, cron, Control UI You, at the keyboard Tasks and queues
Network surface One port open permanently No inbound listening Depends on the deployment
Model Provider of your choice, Ollama included Anthropic Depends on the deployment

Claude Code listens for nothing: close the terminal, and the attack surface disappears. OpenClaw listens permanently, by design, because that’s what it’s asked to do: answer a Telegram message at three in the morning. Hermes Agent, installed the same way in a dedicated article, occupies a third position: an agent designed to run in a container from the start. Our overview of command-line agents places the others, and OpenCode covers the case of the open source coding agent.

Removing everything

A lab gets taken apart. docker compose down on its own leaves the volumes behind, along with the SQLite database and its tokens.

bash
docker compose down --volumes --remove-orphans
docker rmi ghcr.io/openclaw/openclaw:2026.9.2 alpine/openclaw:latest
rm -f .env

# check: nothing should come back
docker ps -a --format '{{.Names}}' | grep -i claw
docker volume ls --format '{{.Name}}' | grep -i claw
docker images --format '{{.Repository}}' | grep -i claw
docker network ls --format '{{.Name}}' | grep -i claw

What to remember

  • Use ghcr.io/openclaw/openclaw: the alpine/openclaw mirror was three months behind on the day of the test (2026.6.9 versus 2026.9.2).
  • The gateway refuses to start with no configuration and goes into a restart loop: write gateway.mode=local before the first up.
  • Publishing on 127.0.0.1 closes the machine’s interfaces, not the Docker network: a neighbouring container reached the gateway with an HTTP 200.
  • internal: true silently removes the published port and access to host.docker.internal: reserve it for gateways driven by exec.
  • The token lives in .env, never in the compose file. state/openclaw.sqlite holds OAuth tokens in plain text and should be treated as a secret.
  • On ClawHub, a “clean” verdict can cover up two “suspicious” signals, an unsigned package, and an unknown provenance. Read the SKILL.md.

Common errors

Publishing the port with no address prefix ports: ["18789:18789"] publishes on every interface, including the local network. Write "127.0.0.1:18789:18789" instead, and on a Linux server add rules to the DOCKER-USER chain: regular INPUT rules never see traffic published by Docker.
Assuming 127.0.0.1 isolates the gateway A container on a different Docker network reached 172.29.0.2:18789/healthz with an HTTP 200. The prefix closes the machine's interfaces, not the daemon. Rely on the gateway's authentication, not on the publishing.
Running docker compose up with no configuration The container loops on Missing config. Run openclaw setup or set gateway.mode=local, and restart: unless-stopped hides the error. Write the configuration with config set --batch-json and --no-deps --entrypoint node before the first start.
Mistaking alpine/openclaw for the official image It's an unofficial mirror, frozen at 2026.6.9 from 21 June 2026 while the official one was at 2026.9.2. The documentation asks you to use ghcr.io/openclaw/openclaw or openclaw/openclaw instead.
Adding internal: true as a simple hardening step Compose then silently ignores the ports section, the Control UI becomes unreachable, and host.docker.internal still resolves but no longer routes: an Ollama running on the host becomes unreachable.

DockerMCPOllamaOpenClawSécuritéSkills

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.