A turnkey Docker setup for running OpenClaw inside a full Ubuntu 24.04 GUI desktop, accessible via web browser (NoVNC), RDP, or VNC.
Everything is pre-installed — Node.js 22, OpenClaw, Google Chrome, and a default Gateway config. On first boot the Gateway starts automatically; just set your AI model and go.
New to Docker? Check out the Beginner's Guide for step-by-step instructions with screenshots.
⚠️ Security notice The default password (claw1234) is published in this README. By default, ports are bound to127.0.0.1only (this host) — safe for local use. Before exposing to your LAN or the internet, always changeCLAW_PASSWORDin.envand review the port-mapping block indocker-compose.yml.
| Component | Details |
|---|---|
| Base OS | Ubuntu 24.04 |
| Architectures | linux/amd64, linux/arm64 (multi-arch manifest — Docker picks the right variant automatically) |
| Desktop | XFCE4 with Korean + CJK + emoji fonts |
| Remote Access | TigerVNC + NoVNC (web), xRDP (Remote Desktop), raw VNC |
| Browser | Google Chrome on amd64 / Chromium on arm64 (Google does not ship chrome-stable for arm64; Chromium is CDP-compatible so OpenClaw browser automation behaves identically). Both ship a --no-sandbox wrapper. |
| Runtime | Node.js 22 (NodeSource) |
| OpenClaw | Latest from npm, default config pre-seeded, Gateway auto-starts, user-local npm prefix for skill installs |
| Agent CLIs | claude (Claude Code) and codex (OpenAI Codex) pre-installed via npm. No credentials baked — run claude or codex once to authenticate. Pinnable via build args CLAUDE_CODE_VERSION / CODEX_VERSION. |
| Desktop Shortcuts | OpenClaw Setup, Dashboard, Terminal |
| Port | Service |
|---|---|
6080 |
NoVNC — access the desktop via web browser |
5901 |
VNC — direct VNC client connection |
3389 |
RDP — Windows Remote Desktop / Remmina |
18789 |
OpenClaw Gateway & Dashboard |
- Docker Engine 20+
docker compose up -dOr standalone (loopback-only — safe default):
docker pull neoplanetz/openclaw-desktop-docker:latest
docker run -d --name openclaw-desktop \
-p 127.0.0.1:6080:6080 -p 127.0.0.1:5901:5901 \
-p 127.0.0.1:3389:3389 -p 127.0.0.1:18789:18789 \
--shm-size=2g --security-opt seccomp=unconfined \
neoplanetz/openclaw-desktop-docker:latest
# To expose on the LAN, first set -e PASSWORD=<strong>, then drop the
# 127.0.0.1: prefix from the -p flags above.If you want to build the image yourself:
docker compose up -d --buildOpen http://localhost:6080/vnc.html and enter the VNC password (default: claw1234, configurable in .env).
Connect to localhost:3389 with any RDP client:
- Windows:
mstsc - macOS: Microsoft Remote Desktop
- Linux: Remmina
Login with your configured username and password (default: claw / claw1234, configurable in .env). Leave Domain blank.
Connect to localhost:5901 with any VNC viewer.
The Docker image ships with Node.js 22, OpenClaw, and a minimal ~/.openclaw/openclaw.json config. On every container start, the entrypoint:
- Starts VNC, NoVNC, and xRDP servers
- Ensures the OpenClaw config exists (regenerates if missing)
- Runs
openclaw-sync-displayto configure DISPLAY / XAUTHORITY targeting (auto-detects VNC vs xRDP session) and writesOPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1to~/.openclaw/.env - Starts the OpenClaw Gateway in the background (
openclaw gateway run) - Sets Chrome as the default XFCE web browser
- Installs a
.bashrchook that auto-syncs the display when switching between VNC and RDP sessions - Ensures
.npmrcwith user-writable prefix (/var/openclaw-npm) exists sonpm install -gworks without root (for clawhub and skill dependencies). The prefix lives outside/home, so installed skills are reset on container recreate — preventing stale openclaw versions from shadowing the image-baked one.
The container ships a systemctl shim that translates OpenClaw's systemd-user calls into direct process management, so openclaw update, openclaw gateway restart, and the dashboard's equivalent flows complete cleanly. The shim is SIGUSR1-first: when the gateway is already running and the on-disk openclaw binary hasn't changed since the daemon started, it sends SIGUSR1 so OpenClaw re-initialises itself in-process (the same mechanism OpenClaw 2026.5+ uses internally for container restarts). This preserves the bound port, Control-UI WebSocket sessions, and per-browser device-pairing approvals — so the dashboard doesn't disconnect on every config write. The full stop + spawn path kicks in only when (a) no gateway is running, or (b) the binary mtime is newer than the running gateway's start time — exactly what openclaw update produces, where a fresh process image is required to load the new code. The gateway unit file is auto-registered on first boot; no manual openclaw gateway install is needed.
Three icons are placed on the XFCE desktop:
| Icon | What It Does |
|---|---|
| OpenClaw Setup | Runs openclaw onboard — configure AI model/auth, channels (Telegram, Discord, etc.), and skills. The gateway daemon install at the end completes cleanly via the systemctl shim. |
| OpenClaw Dashboard | Runs openclaw dashboard — opens Chrome with http://127.0.0.1:18789/ and an auto-login token. The setup wizard's "Hatch in Browser" path is rewritten to the same origin by our xdg-open wrapper, so a single device-pairing approve covers every way to open the dashboard. |
| OpenClaw Terminal | Opens an XFCE terminal with the openclaw CLI ready. |
Double-click "OpenClaw Setup" on the desktop. The onboarding wizard walks through:
- Model / Auth — choose a provider (OpenAI Codex OAuth, Anthropic API key, etc.)
- Channels — connect Telegram, Discord, WhatsApp, or skip
- Skills — install recommended skills or skip
- Gateway daemon — installs cleanly via the systemctl shim
The wizard automatically restarts the Gateway and opens the Dashboard when finished.
Approve the dashboard browser once. OpenClaw 2026.5+ treats every browser/CLI as a separate device with its own pairing identity. The setup wizard's internal client is auto-approved, but the "Hatch in Browser" tab opens as a new device and surfaces "Device pairing required" on first connect. The setup terminal prints the exact command — copy the request id from the dashboard error page and run:
openclaw devices approve <request-id>The gateway connect failed / using local fallback lines you'll see in the approve output are the intended chicken-and-egg fallback (the CLI itself is a freshly created device, so it can't approve other devices through the gateway yet) — the final Approved … line is success. That browser is permanent from then on. openclaw devices list shows all paired devices.
If you have a ChatGPT Plus/Pro subscription, select "OpenAI Codex (ChatGPT OAuth)" during onboarding. A browser window opens for you to log into your OpenAI account. After authorization, the model is set automatically.
Or run directly in the terminal:
openclaw models auth login --provider openai-codex --set-defaultopenclaw config set agents.defaults.model.primary anthropic/claude-sonnet-4-6
echo 'ANTHROPIC_API_KEY=sk-ant-...' >> ~/.openclaw/.envopenclaw config set agents.defaults.model.primary openai/gpt-4o
echo 'OPENAI_API_KEY=sk-...' >> ~/.openclaw/.envopenclaw status # Overall status
openclaw gateway status # Gateway-specific status
openclaw models status # Model/auth status
openclaw config file # Print the active config file path
openclaw config get gateway.port # Get a specific config value (dot path)
openclaw dashboard # Open Dashboard with auto-login tokenOpenClaw 2026.5.18+ refuses to bind the gateway to a non-loopback interface without auth, and Docker port-forwarding requires that bind. The entrypoint handles this automatically — you do not normally need to touch it:
- A 256-bit hex token is generated on first boot and persisted to
~/.openclaw/.env(kept acrossdocker compose down/upby theopenclaw-homevolume). - The token is auto-loaded into every login shell via
/etc/profile.d/openclaw-gateway-env.sh, so all in-container CLI commands and the systemctl-shim restart path pick it up transparently. - The gateway runs with
--auth token --bind auto.openclaw status,openclaw devices list,openclaw-update, etc. reuse the same token without any user action.
Accessing the Dashboard from inside the container (recommended) — Use the OpenClaw Dashboard desktop shortcut (NoVNC/RDP) or run openclaw dashboard in a terminal. Either path launches Chrome with the auto-login URL passed as a command-line argument — no clipboard, no token typing.
Accessing the Dashboard from the host browser — docker exec does not have access to the container's X clipboard (the image omits xclip/xsel), so openclaw dashboard --no-open from outside prints the bare URL without the token. Assemble the working URL yourself by appending the token as a fragment:
TOKEN=$(docker exec openclaw-desktop sh -lc \
'for f in /home/*/.openclaw/.env; do [ -f "$f" ] && grep ^OPENCLAW_GATEWAY_TOKEN= "$f" | cut -d= -f2- && exit; done; exit 1')
echo "http://127.0.0.1:18789/#token=${TOKEN}"Open the printed URL in the host browser. The #token=… fragment is read by the dashboard JS to authenticate the WebSocket; it is NOT sent in the HTTP request, so it does not appear in access logs.
Overriding the token — Set OPENCLAW_GATEWAY_TOKEN=<value> in .env or docker-compose.yml's environment: block. The entrypoint will honor the pre-set value instead of generating a fresh one (useful with a secrets manager). Custom values must be 32-256 characters using only A-Z, a-z, 0-9, ., _, ~, and -.
Two tracks, depending on what you want to update:
A. OpenClaw itself (most common). Run inside the container:
openclaw updateThis npm install's the latest OpenClaw to /var/openclaw-npm/, then triggers a systemctl --user restart that goes through our shim. Because the on-disk binary is now newer than the running gateway, the shim's mtime check picks the full stop + spawn path and the new code is loaded. No container restart required, no dashboard re-pairing, channel state preserved.
B. Docker image (Node, Chrome, OS packages, shim itself). Run on the host:
docker compose pull # Fetch the new image from Docker Hub
docker compose down # Stop the container (named volume preserved)
docker compose up -d # Recreate against the new imageThe openclaw-home volume keeps your workspace, channel settings, paired devices, and auth tokens across recreates. Use docker compose down -v only if you want a fully clean slate (you'll repeat onboarding).
After either path, you can verify the shim chose the expected branch:
cat /tmp/systemctl-shim.log
# Expected lines:
# restart: SIGUSR1 in-process restart of PID … succeeded (config-reload-style restarts)
# restart: binary newer than gateway PID … -- full restart (right after `openclaw update`)Pre-seeded at ~/.openclaw/openclaw.json:
{
gateway: {
mode: "local",
port: 18789,
bind: "lan",
controlUi: {
allowedOrigins: [
"http://127.0.0.1:18789",
"http://localhost:18789",
],
},
auth: {
rateLimit: { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 },
},
},
browser: {
enabled: false,
defaultProfile: "openclaw",
noSandbox: true,
},
plugins: {
entries: {
browser: {
enabled: true,
},
},
},
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
},
},
env: {
vars: {
TZ: "Asia/Seoul",
},
},
}bind: "lan"— listens on all interfaces so the host can accesshttp://127.0.0.1:18789/controlUi.allowedOrigins— explicit list of browser origins that may open the Dashboard WebSocket. v1.4.19 replaced the older["*"]wildcard (flagged CRITICAL byopenclaw security audit) with the two host-browser URL forms our entrypoint andxdg-openwrapper produce. LAN exposure (uncommented LAN ports indocker-compose.yml): add your host's LAN origin here, e.g."http://192.168.1.10:18789".auth.rateLimit— token-auth attempt limiter (10 attempts per 60s, 5-minute lockout). The token has 256 bits of entropy so brute force is computationally infeasible, but the audit warns whenbind != loopbackwithout rate limiting.browser.enabled: false— CDP browser is disabled by default; setOPENCLAW_BROWSER_ENABLED=truein.envto enablebrowser.defaultProfile/browser.noSandbox— uses a dedicatedopenclawChrome profile and disables the sandbox (required in Docker)plugins.entries.browser.enabled: true— browser plugin is registered so agents can use browser tools when the browser is enabled- No AI model is configured by default — set one via onboarding or CLI
Edit the .env file in the project root (same directory as docker-compose.yml):
CLAW_USER=myname
CLAW_PASSWORD=mypasswordThen rebuild:
docker compose up -d --buildIf changing the username after a previous run, delete the old volume first:
docker compose down -v && docker compose up -d --build
These are set automatically from .env via docker-compose.yml:
.env Variable |
Container Env | Default | Description |
|---|---|---|---|
CLAW_USER |
USER |
claw |
Linux username |
CLAW_PASSWORD |
PASSWORD |
claw1234 |
VNC / RDP / sudo password |
OPENCLAW_VERSION |
(build arg) | latest |
OpenClaw npm package version (e.g. latest, 2026.3.28) — used at docker compose build time |
| — | VNC_RESOLUTION |
1920x1080 |
Desktop resolution |
| — | VNC_COL_DEPTH |
24 |
Color depth |
| — | TZ |
Asia/Seoul |
Timezone |
| — | OPENCLAW_ALLOW_INSECURE_PRIVATE_WS |
1 |
Allows plaintext ws:// to Docker-internal private IPs (details) |
OPENCLAW_GATEWAY_TOKEN |
OPENCLAW_GATEWAY_TOKEN |
(auto) | Gateway auth token. Required since OpenClaw 2026.5.18+. Entrypoint generates a 256-bit hex token on first boot and persists it to ~/.openclaw/.env; set this to override with a fixed value (e.g. from a secrets manager). See Gateway Authentication. |
OPENCLAW_BROWSER_ENABLED |
OPENCLAW_BROWSER_ENABLED |
false |
Enable OpenClaw CDP browser (Chrome profile: openclaw, --no-sandbox) |
OPENCLAW_DISPLAY_TARGET |
OPENCLAW_DISPLAY_TARGET |
auto |
Display targeting policy: auto, vnc, rdp |
| — | OPENCLAW_X_DISPLAY |
— | Hard override for DISPLAY (e.g. :1, :10) |
| — | OPENCLAW_X_AUTHORITY |
— | Hard override for XAUTHORITY path |
The openclaw-home named volume mounts to the configured user's home directory (/home/claw by default). This preserves:
- OpenClaw config, credentials, and conversation history
- Chrome profile and bookmarks
- Desktop customizations
- SSH keys, shell history, etc.
Data survives docker compose down / up. Only docker volume rm openclaw-home destroys it.
Not persisted: the npm global prefix lives at
/var/openclaw-npm(outside the home volume), so packages installed viaclawhubornpm install -gare reset on container recreate. This is intentional — it prevents a stale user-installedopenclawfrom shadowing the image-baked version when you upgrade. Reinstall skills after recreate.
Standard flow — from a terminal inside the container:
openclaw-updateThis single command handles:
- Runs
openclaw update(npm package upgrade + gateway restart) - Approves the post-restart self scope-upgrade pending via
openclaw-pair-latest - Verifies via
openclaw gateway status --deep
On OpenClaw 2026.5+, the CLI's own device token sits at operator.pairing scope right after a gateway restart. Any follow-up command needing operator.read (such as gateway status --deep) emits a fresh scope-upgrade pending — but by design no path lets a device auto-approve its own scope upgrade. Without an explicit approve, the gateway stays at Gateway did not become healthy after restart + Capability: pairing-pending.
openclaw-update wraps the follow-up step into one invocation. From v1.4.16 onwards, both the wrapper and the entrypoint first-boot auto-call invoke the helper with --self-scope-upgrade-only, which enforces entry.deviceId == ~/.openclaw/identity/device.json + clientId == "cli" + role == "operator" + scopes ⊆ {operator.pairing, operator.read} inside the helper. External browser/dashboard device pendings are rejected by the filter — the trust boundary is now coded, not just docstring-claimed.
If you ran openclaw update directly (without the wrapper), the manual breakdown is in the scope-upgrade pending approval troubleshooting item.
openclaw-update |
docker compose build |
|
|---|---|---|
| Speed | Fast (in current container) | Slow (rebuilds image) |
| Persistence | Current container only — reverts to image-baked version on docker compose down/up |
Permanent — survives container recreate |
| When | Daily minor updates, quick checks | Major / release-cadence upgrades, production deploys |
Permanent upgrade:
# OPENCLAW_VERSION=latest in .env / Dockerfile pulls the freshest at build time
docker compose build --no-cache
docker compose down # omit -v to preserve the user home volume
docker compose up -dAfter a rebuild, entrypoint calls the self scope-upgrade approval once during first boot (Pairing : self scope-upgrade approved appears in the boot log), so the container starts healthy with no extra step.
This setup includes several workarounds for running a full GUI + browser + OpenClaw inside Docker:
| Issue | Solution |
|---|---|
| No systemd | systemctl shim translates systemd-user calls into direct process management; entrypoint supervises VNC and xRDP startup |
| Shim restart racing OpenClaw's container-aware in-process restart | Shim's restart is SIGUSR1-first: signals the live gateway to re-init itself in-place (matches OpenClaw 2026.5+ container mode). Falls back to stop + spawn only on cold start, or when the on-disk binary mtime is newer than the running gateway's start time (i.e. right after openclaw update, where a new process image is required) |
| Process-title rename across OpenClaw versions | Gateway daemon's process.title is openclaw-gateway on 2026.4.x and openclaw on 2026.5+. Entrypoint, display-sync, and shim accept both titles, then require the process to own the gateway listener so ordinary openclaw CLI commands are not mistaken for the daemon |
| Chrome needs sandbox | Wrapper script adds --no-sandbox to every launch |
xdg-open uses Docker internal IP |
Wrapper rewrites 172.x.x.x / 10.x.x.x URLs to 127.0.0.1 — matching what openclaw dashboard already emits. Chrome treats localhost and 127.0.0.1 as distinct security origins with separate localStorage scopes, so unifying every dashboard-open path on the same form is what makes a single device-pairing approve survive subsequent dashboard opens; mixing forms forces double pairing (one per origin). For the same reason every dashboard URL hint we publish — entrypoint startup banner, this README, openclaw.json comment, beginner guide — consistently uses 127.0.0.1:18789 |
| Browser detaches from terminal | setsid in xdg-open wrapper prevents SIGHUP on terminal close |
| Chrome profile lock conflicts | Stale SingletonLock files cleaned once at container start |
| XFCE default browser | Custom exo-helper + mimeapps.list set on every start |
VNC password (vncpasswd missing) |
3-tier fallback: vncpasswd binary → openssl → pure Python DES |
| Firefox snap broken in Docker | Replaced with Google Chrome deb package |
Gateway health check blocks ws:// to non-loopback |
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 permits plaintext ws:// to RFC 1918 private IPs (Docker internal network only, added in v2026.2.19) |
| VNC↔RDP display mismatch | openclaw-sync-display helper auto-detects active session (VNC :1 vs xRDP :10+), restarts gateway with correct DISPLAY; .bashrc hook catches transitions |
openclaw update leaves dashboard showing "update available" |
systemctl shim's restart handler detects the newer-on-disk binary via mtime and forces a fresh process spawn, so the new code is actually loaded |
npm install -g needs root |
.npmrc sets prefix=/var/openclaw-npm (outside /home) so global installs go to a user-writable directory and stay ephemeral across recreates; PATH exported in .bashrc |
docker compose logs openclaw-desktopCheck for errors in VNC startup or config validation.
# Replace 'claw' with your CLAW_USER if changed in .env
docker exec -it openclaw-desktop bash
su - claw -c "vncserver -kill :1"
su - claw -c "vncserver :1 -geometry 1920x1080 -depth 24 -localhost no"docker exec -it openclaw-desktop /etc/init.d/xrdp restart# Replace 'claw' with your CLAW_USER if changed in .env
docker exec -u claw openclaw-desktop openclaw status
# Manual restart:
docker exec -u claw openclaw-desktop bash -c \
"nohup openclaw gateway run >> ~/.openclaw/gateway.log 2>&1 & disown"Earlier versions of this image surfaced a "systemd not available" message because Docker containers have no systemd. The current image ships a shim that handles these calls transparently; you should no longer see this message during onboarding. If you do, check that /usr/bin/systemctl is a symlink to /usr/local/bin/systemctl-shim.
The browser opened with a Docker internal IP instead of 127.0.0.1. Close it and use the "OpenClaw Dashboard" desktop shortcut, which runs openclaw dashboard with the correct URL and token.
Expected on a new browser's first connect — by design, OpenClaw 2026.5+ requires explicit approval for every browser device (upstream policy: "Operator, browser, Control UI … pairing still require manual approval"). One-time per browser. Single approve is permanent across close/reopen and across the setup-wizard "Hatch in Browser" tab vs the desktop "OpenClaw Dashboard" icon, because both paths land on the same http://127.0.0.1:18789/ origin.
Recommended:
openclaw-pair-latestThis helper reads the gateway's own state file (~/.openclaw/devices/pending.json), picks the latest pending requestId, and runs openclaw devices approve on it — no UUID copy-paste. Handles both first-time pairing and the scope-upgrade pending case with the same command. It's not a daemon and not auto-approval; you invoke it explicitly, same security model as approving by hand.
Manual form (if you prefer to copy the requestId yourself):
openclaw devices approve <request-id-shown-on-the-pairing-screen>After approve, refresh the dashboard. List paired devices anytime with openclaw devices list. The paired.json file at ~/.openclaw/devices/paired.json should contain exactly one openclaw-control-ui browser entry after a clean setup — if you see two, you're probably running an older image that didn't yet align the xdg-open rewrite target to 127.0.0.1; rebuild from :latest (v1.4.14+) and the duplicate disappears.
Note: pairing requestIds expire after ~5 minutes. If approve fails with unknown requestId, refresh the dashboard once to get a fresh id, then run openclaw-pair-latest again. The first run of openclaw devices approve on a fresh container may print gateway connect failed / using local fallback — that's the intended chicken-and-egg fallback (the CLI is itself a fresh device); the final Approved … line is success.
Why no auto-approve daemon? We deliberately don't ship one. OpenClaw's upstream policy is explicit about manual approval for operator/Control UI devices, and a default-on auto-approver would weaken security for anyone enabling LAN exposure (commented block in docker-compose.yml). The openclaw-pair-latest helper keeps the manual-approve trust boundary intact and just removes the typing friction.
OpenClaw 2026.5+ auto-creates a CLI device on the first gateway connect, but seeds it with operator.pairing scope only. Any later command that needs operator.read (e.g. openclaw gateway status --deep, openclaw devices list) emits a fresh scope-upgrade request — and by design there is no path for a device to auto-approve its own upgrade. The result: each probe surfaces a new requestId, ~/.openclaw/devices/pending.json accumulates entries, and the health probe stays failed.
Symptom:
Connectivity probe: failed
scope upgrade pending approval (requestId: <uuid>)
Capability: pairing-pending
This state shows up especially often right after openclaw update — the previous device tokens look under-privileged under the new core's permission model, and the update's Restarting service... step ends with Gateway did not become healthy after restart. Same root cause.
Fix:
openclaw-pair-latestThe helper extracts the freshest requestId from pending.json and approves it. On the first call you'll see the intended local fallback sequence that breaks the chicken-and-egg:
gateway connect failed: ... scope upgrade pending approval (requestId: <new>)
Direct scope access failed; using local fallback.
Approved <deviceId> (<requestId>)
Verify:
openclaw gateway status --deep | grep -E "Connectivity|Capability"
# Connectivity probe: ok
# Capability: read-only (or ready/granted — no longer pairing-pending)If the first call doesn't clear it (transient state), run openclaw-pair-latest once more. Stale requestIds older than ~5 minutes expire and are rejected as unknown requestId, so surface a fresh one with openclaw gateway status --deep immediately before re-running the helper.
openclaw-desktop-docker/
├── .env # User configuration (CLAW_USER, CLAW_PASSWORD)
├── Dockerfile # Ubuntu 24.04 base image
├── docker-compose.yml # Compose configuration
├── entrypoint.sh # Runtime: VNC, xRDP, Chrome config, Gateway
├── README.md # Documentation (EN, KO, ZH, JA)
├── assets/ # Images & architecture diagrams
│ ├── architecture_*.svg
│ ├── dockerized_openclaw.png
│ └── openclaw_desktop_web.png
├── configs/ # Config templates (copied at build/runtime)
│ ├── vnc/xstartup # VNC session startup
│ ├── xrdp/startwm.sh # xRDP session startup
│ ├── xrdp/reconnectwm.sh # xRDP reconnection hook
│ └── ...
├── scripts/ # Helper scripts
│ └── openclaw-sync-display # Policy-based X11 display targeting
└── docs/ # Guides & changelog
├── CHANGELOG.md
├── DOCKERHUB_OVERVIEW.md
├── GUIDE_FOR_BEGINNERS.*.md
└── images/ # Guide screenshots

