Phase 6 — Deployment

Phase 6 — Deployment

Run OpenClaw as a system service that starts at boot, survives reboots, and is locked down at the network level. Assumes Phase 3 security baseline complete.

  • Coming from Phase 1 quick start? Each isolation model section below covers migrating your existing config to the dedicated user/VM — stop the personal gateway first, then follow the migration steps in your chosen section.
  • Fresh dedicated machine? Each section installs OpenClaw from scratch in the right place — no prior installation needed.

Choose your deployment method and skip the others — each section is self-contained:

Shared sections (apply to all methods): Secrets Management (read first) | Firewall | Tailscale ACLs | Signal Setup | Verification | Emergency

Deployment Methods Overview

MethodIsolationSandboxingBest for
Pragmatic Single AgentOS user or VMGuard plugins (no Docker)Full native OS access, simplicity
Docker ContainerizedContainer boundaryDocker (gateway runs inside container)VPS, cloud, fastest path
Docker Isolation (recommended)OS user boundaryDocker (per-agent sandboxing)Dedicated hardware, full control
VM: macOS VMsKernel-level VMTool policy only (no Docker)macOS hosts, strongest host isolation
VM: Linux VMsKernel-level VMDocker inside VMAny host, strongest combined

Hosting Options

  • Local/dedicated hardware (Mac Mini, NUC, etc.) — this guide’s primary focus
  • Cloud VPS (Hetzner, DigitalOcean, Linode) — use Docker Containerized or Linux VM method
  • GCP/AWS/Azure — works with any small VM; use Docker Containerized for simplest setup
  • Hosted Mac (MacStadium, AWS EC2 Mac) — use macOS VM or Docker Isolation method

Decision: Foreground vs Service

Foreground (openclaw start)System service
Starts at bootNoYes
Survives logoutNoYes
Log managementTerminalLog files
Best forDevelopment, testingProduction

For anything beyond testing, run as a system service.


Deployment: Choose Your Method

Before setting up the service, choose your deployment method. See Security: Deployment Isolation Options for the full trade-off analysis of the isolation models.

  • Pragmatic Single Agent — two-agent setup (main + search), all five guard plugins, no Docker. Full native OS access. See the dedicated guide for setup.
  • Docker Containerized — official docker-setup.sh, gateway runs inside a Docker container. Simplest path.
  • Docker Isolation (recommended) — multi-agent gateway as openclaw user with Docker sandboxing. macOS or Linux.
  • VM: macOS VMs (Lume / Parallels) — single macOS VM, multi-agent gateway, no Docker inside VM. macOS hosts only.
  • VM: Linux VMs (Multipass / KVM / UTM) — Linux VM with Docker inside. Strongest combined posture (VM boundary + Docker sandbox). macOS or Linux hosts.

The multi-agent isolation models (Docker Isolation, macOS VMs, Linux VMs) all use the same multi-agent architecture with sessions_send delegation. They differ in the outer boundary and internal sandboxing. The Pragmatic Single Agent is a simpler alternative that trades container isolation for full native OS access — it still uses a search agent for web delegation (and content-guard at that boundary) but runs without Docker. See Phase 3: Deployment Isolation Options for the full comparison.

  • Docker Isolation: OS user boundary + Docker sandbox. LaunchDaemon/systemd on host (LaunchAgent for auto-login setups).
  • VM: macOS VMs: Kernel-level VM boundary + standard user (no sudo). LaunchAgent inside VM (auto-login required). No Docker.
  • VM: Linux VMs: Kernel-level VM boundary + Docker sandbox inside VM. systemd inside VM.

Secrets Management (All Methods)

Keep openclaw.json secrets-free — use ${ENV_VAR} references in config, store actual values in the service plist (macOS) or environment file (Linux). This applies to all deployment methods.

Choose your secrets method:

  • Docker isolation (macOS) → LaunchDaemon EnvironmentVariables block (or LaunchAgent for auto-login setups)
  • Docker isolation (Linux)/etc/openclaw/secrets.env file
  • VM: macOS/Linux → SSH-load secrets before gateway start

Secrets to externalize

SecretEnv varNotes
Gateway tokenOPENCLAW_GATEWAY_TOKENIncluded in all plist/systemd examples below
Anthropic API keyANTHROPIC_API_KEYSDK reads from env directly
Brave search keyBRAVE_API_KEYReferenced as ${BRAVE_API_KEY} in config
OpenRouter keyOPENROUTER_API_KEYIf using Perplexity via OpenRouter
GitHub tokenGITHUB_TOKENFine-grained PAT — see GitHub token setup below
(channel-guard and content-guard both require OPENROUTER_API_KEY)See plugin setup

Empty env vars cause startup failure. If a ${VAR} reference resolves to an empty string, the gateway exits with EX_CONFIG (exit 78). For optional keys not yet provisioned (e.g., BRAVE_API_KEY when using Perplexity instead), use a non-empty placeholder like "not-configured" rather than leaving the variable empty or unset.

Version note (2026.2.16): Telegram bot tokens are now auto-redacted from gateway logs (same mechanism as redactSensitive: "tools" for other secrets).

External Secrets Management (2026.2.26+): OpenClaw now supports a dedicated openclaw secrets workflow for centralized credential management. This lets you store secrets outside your config entirely — using a secrets provider or vault — and reference them via SecretRef placeholders in config. Key commands: openclaw secrets audit (find hardcoded secrets), openclaw secrets configure (set up provider), openclaw secrets apply (write SecretRefs to config), openclaw secrets reload (activate at runtime without restart). See official docs for provider-specific setup. The ${ENV_VAR} method documented below remains fully supported.

GitHub token setup

Use a fine-grained personal access token (not a classic token). Fine-grained PATs let you scope access to specific repositories with minimal permissions.

  1. Go to github.com/settings/personal-access-tokens/new

  2. Token name: e.g., openclaw-workspace-sync

  3. Expiration: set a reasonable expiry (e.g., 90 days) and create a reminder to rotate

  4. Repository access: select “Only select repositories” → pick only your workspace repos

  5. Permissions:

    PermissionAccessWhy
    ContentsRead and writePush/pull workspace commits
    MetadataReadAutomatically selected (read-only)

    No other permissions needed. Do not grant “All repositories” access.

  6. Click Generate token — the value starts with github_pat_

Set the token in your LaunchAgent plist (macOS) or environment file (Linux) as GITHUB_TOKEN. The gh CLI and git push/pull over HTTPS both read from this env var.

Rotation: When the token expires, generate a new one with the same settings and update the plist/env file. Restart the gateway to pick up the new value.

Config references

In openclaw.json, reference secrets with ${...} — OpenClaw substitutes at startup:

{
  "tools": {
    "web": { "search": { "apiKey": "${BRAVE_API_KEY}" } }
  },
  "gateway": {
    "auth": { "token": "${OPENCLAW_GATEWAY_TOKEN}" }
  }
}

Result: openclaw.json contains zero plaintext secrets — safe to copy between VMs, diff, or version-control.

CLI commands and env vars

The ${VAR} substitution happens at config load time — not just for the gateway service, but for every openclaw command that reads openclaw.json (e.g., openclaw doctor, openclaw memory index, openclaw config get). If the referenced env vars aren’t available, the command exits with EX_CONFIG (exit 78).

The problem: Plist EnvironmentVariables are only injected by launchd into the service process. When you run sudo -u openclaw openclaw doctor from an admin shell, those vars aren’t present.

Solutions (pick one):

  1. ~/.openclaw/.env file (recommended) — OpenClaw loads this automatically regardless of how it’s invoked. Put non-secret config vars here and keep actual secrets in the plist. Or duplicate secrets here with locked-down permissions (chmod 600):

    sudo -u openclaw tee /Users/openclaw/.openclaw/.env > /dev/null << 'EOF'
    ANTHROPIC_API_KEY=sk-ant-...
    OPENCLAW_GATEWAY_TOKEN=...
    BRAVE_API_KEY=BSA...
    EOF
    sudo chmod 600 /Users/openclaw/.openclaw/.env

    Note: .env files are non-overriding — plist env vars take precedence if both are set.

  2. Pass vars inline — for one-off commands:

    sudo -u openclaw env ANTHROPIC_API_KEY=sk-ant-... openclaw doctor
  3. env.shellEnv — import missing vars from the user’s login shell (if secrets are set there):

    { "env": { "shellEnv": { "enabled": true } } }

Env var precedence (highest wins): process environment → CWD .env~/.openclaw/.env → config env block → shell import. See official docs for details.


Docker Containerized Gateway

Simplest path. The official Docker setup runs the entire gateway inside a container with persistence, auto-restart, and basic security. No per-agent sandboxing — all agents share the container.

When to use: Cloud VPS, quick evaluation on any Docker-capable host, or when per-agent Docker sandboxing isn’t needed.

Quick start:

curl -fsSL https://openclaw.ai/docker-setup.sh | bash

For production, review the script before piping to shell, or download and inspect first: curl -fsSL https://openclaw.ai/docker-setup.sh -o docker-setup.sh && less docker-setup.sh && bash docker-setup.sh

This creates a Docker Compose setup with:

  • Persistent data volume (survives container restarts)
  • Auto-restart on crash (restart: unless-stopped)
  • Loopback binding (not exposed to network by default)
  • Environment variable passthrough for secrets

What it doesn’t provide (compared to Docker Isolation below):

  • No dedicated OS user — the container runs as whatever user Docker assigns
  • No per-agent sandboxing — all agents share the same container filesystem and network
  • No LaunchAgent/systemd integration — relies on Docker’s restart policy

For production deployments on dedicated hardware where you want per-agent isolation and OS-level service management, use Docker Isolation below instead.

Timezone support (2026.3.13-1+): Set OPENCLAW_TZ in the container environment to control the gateway’s timezone (e.g., OPENCLAW_TZ=Europe/Stockholm). Without this, the container uses UTC. This affects daily memory file naming, session reset times, and cron job scheduling. Pass it via your Docker Compose environment: block or docker run -e OPENCLAW_TZ=....

Security — Docker build context token leak (2026.3.13-1+): Do not pass OPENCLAW_GATEWAY_TOKEN or other secrets as Docker build arguments (--build-arg). Build args are embedded in the image layer cache and visible via docker history. Use runtime environment variables (-e / Compose environment:) or Docker secrets for all credential injection.

Official docs: See docs.openclaw.ai for the latest Docker setup instructions and options.


Docker Isolation

Recommended approach. Works on both macOS and Linux. Single gateway, multi-agent, Docker sandboxing for internal isolation.

Automated setup: For a fresh dedicated macOS machine, see scripts/docker-isolation/ — three scripts that automate everything below.

Installation Scope

The OpenClaw installer (curl ... | bash) runs npm install -g openclaw, placing files in the global npm prefix. On a service deployment, global install is preferred:

  • Global install (recommended): Admin installs OpenClaw once. The openclaw user — in the staff group by default on macOS — can run /opt/homebrew/bin/openclaw without its own Node.js install. The LaunchAgent plist references these paths directly.
  • Per-user install: Alternative if you can’t modify global packages. Requires updating ProgramArguments in the plist to point at the user’s local npm prefix (e.g., /Users/openclaw/.npm-global/...).

On Linux, global install places the binary at /usr/local/bin/openclaw — accessible to all users by default.

Warning: On macOS, the staff group has write access to /opt/homebrew by default. Any user in staff (including the openclaw user) can modify binaries there — a compromised openclaw user could trojan /opt/homebrew/bin/node, affecting all users who run it. Mitigations: (1) sudo chown root:wheel /opt/homebrew/bin/node to remove group write (re-apply after brew upgrade), or (2) install Node.js per-user via nvm so each user runs their own copy. This is lower risk for single-user deployments where only the openclaw user runs Node.js.

Dedicated OS User

If you haven’t already (from Phase 3 ), create a dedicated non-admin user:

VM isolation: Skip this section — you’ll create a dedicated user inside the VM instead. macOS VMs: see Dedicated user (inside VM) . Linux VMs: see Dedicated user (inside Linux VM) .

macOS:

sudo sysadminctl -addUser openclaw -fullName "OpenClaw" -password "<temp>" \
  -home /Users/openclaw -shell /bin/zsh
sudo passwd openclaw

# Create home directory if not auto-created
sudo mkdir -p /Users/openclaw
sudo chown -R openclaw:staff /Users/openclaw

The user is automatically in the staff group, which gives read access to /opt/homebrew (where Node.js and OpenClaw are installed). No admin group membership needed.

Linux:

sudo useradd -m -s /bin/bash openclaw
sudo passwd openclaw

Mixed-use machine (personal data on host)? Creating a dedicated openclaw user doesn’t automatically protect your personal files. Lock down your admin home directory:

chmod 700 /Users/youradmin    # macOS — replace with your username
chmod 700 /home/youradmin     # Linux

Without this, the openclaw user may be able to read world-readable files in your home directory (macOS doesn’t always default home directories to 700). Also be aware of residual multi-user exposure that no permission change fixes:

  • Process listingsps aux shows all users’ processes and command-line arguments. Never run commands with secrets in arguments (e.g., curl -H "Authorization: Bearer sk-...")
  • Shared temp directories/tmp and /var/tmp are accessible by all users
  • Mounted volumes — external drives and NAS mounts are typically world-readable

These are standard multi-user OS risks, not OpenClaw-specific. On a dedicated machine with no personal data, these are non-issues — see the dedicated machine note .

Docker Group Membership

The openclaw user needs access to the Docker socket for agent sandboxing:

macOS:

sudo dseditgroup -o edit -a openclaw -t user docker

Linux:

sudo usermod -aG docker openclaw

Security note: On Linux, the docker group grants effective root access on the host via the Docker socket. For bare Linux deployments, this is an accepted risk — see the warning at Linux VM isolation . The dedicated machine posture or VM boundary contains this risk. On macOS, Docker Desktop manages access through its own application model — the docker group controls CLI access but doesn’t grant the same host-level root equivalent.

Verify access:

sudo -u openclaw docker ps

This should list running containers (or show an empty list if none running) without errors. If you see “permission denied”, the group membership hasn’t taken effect — log out and back in, or restart the daemon.

Install OpenClaw

If OpenClaw is already installed globally (e.g., via Homebrew or by the admin user), skip the install and verify the openclaw user can access it:

sudo -u openclaw openclaw --version
sudo -u openclaw openclaw doctor

Two sudo -u openclaw patterns:

  • Bare form: sudo -u openclaw <cmd> — for simple commands
  • bash -c form: sudo -u openclaw bash -c 'cd ... && HOME=... <cmd>' — when command requires specific working directory or HOME

Otherwise, install as the openclaw user:

sudo -u openclaw bash -c 'curl -fsSL https://openclaw.ai/install.sh | bash'
sudo -u openclaw openclaw --version
sudo -u openclaw openclaw doctor

Download and review the script before running, or verify the source URL.

Then either migrate from your personal user (below) or create a fresh config:

# Fresh install (skip if migrating)
sudo -u openclaw openclaw setup

Do not use openclaw onboard --install-daemon or openclaw gateway install — these install a LaunchAgent with label ai.openclaw.gateway under the current user. We create our own LaunchAgent (label ai.openclaw.gateway) under the dedicated openclaw user, with explicit secrets and path control. The disable-launchagent marker prevents OpenClaw from auto-installing its own plist.

The ProgramArguments in the plist must match where OpenClaw is actually installed. The “Verify paths” step before creating the plist covers this.

Required config: gateway.mode

The gateway refuses to start unless gateway.mode is set in openclaw.json. Add this to the config (via openclaw setup or manually):

{
  "gateway": {
    "mode": "local"
  }
}

Without this, the gateway exits immediately. Run openclaw doctor to diagnose startup failures — it checks config validity, API key availability, Docker access, and file permissions. Fix any issues it reports before proceeding.

Move OpenClaw data

If you were running as your own user, move the data instead of running openclaw setup:

sudo cp -r ~/.openclaw /Users/openclaw/.openclaw
sudo chown -R openclaw:staff /Users/openclaw/.openclaw

Update all paths in openclaw.json to use /Users/openclaw/.openclaw/... (macOS) or /home/openclaw/.openclaw/... (Linux).

Log directory

The gateway auto-creates agent, workspace, and session directories on startup. The log directory must exist before the service starts (launchd won’t create it):

sudo -u openclaw mkdir -p /Users/openclaw/.openclaw/logs

File permissions

After the first successful gateway start (which creates the directory tree), set ownership and lock down permissions:

# Set ownership first (critical — setup/copy commands run as root)
sudo chown -R openclaw:staff /Users/openclaw/.openclaw

# Then restrict permissions
sudo chmod 700 /Users/openclaw              # Lock down home directory itself (on macOS, this may exclude it from Spotlight indexing and Time Machine backups — acceptable for a service account)
sudo chmod 700 /Users/openclaw/.openclaw
sudo chmod 600 /Users/openclaw/.openclaw/openclaw.json
sudo chmod 600 /Users/openclaw/.openclaw/credentials/*.json
sudo chmod 600 /Users/openclaw/.openclaw/agents/*/agent/auth-profiles.json
sudo chmod 600 /Users/openclaw/.openclaw/identity/*.json
sudo chmod -R 600 /Users/openclaw/.openclaw/credentials/whatsapp/default/*
sudo chmod 700 /Users/openclaw/.openclaw/credentials/whatsapp

macOS: LaunchDaemon

VM isolation: macOS VMs — use the LaunchAgent (Inside VM) section below (VMs typically configure auto-login). Linux VMs — use Linux: systemd .

A LaunchDaemon runs in the system domain — starts at boot before any user logs in, no GUI session required. The UserName and GroupName keys make launchd run the process as the dedicated openclaw user. This is the correct pattern for dedicated headless service accounts.

After starting, verify the process UID: ps aux | grep openclaw — confirm the USER column shows openclaw, not root. The UserName key is Apple’s official mechanism for privilege drop in system daemons.

LaunchDaemon (recommended)LaunchAgent (auto-login only)
Starts at bootYes — no GUI session requiredOnly if user has GUI auto-login configured
openclaw gateway restartDoes not work — use launchctl insteadWorks
OrbStack Docker socketVia /var/run/docker.sock (OrbStack must be running)Automatic — same user session
Agent persistence via LaunchAgentsBlocked (no gui/<uid> domain)Possible
When to useDedicated headless service account (most deployments)Personal Mac or VM with auto-login

Label convention: OpenClaw’s built-in service installer (openclaw gateway install) uses the label ai.openclaw.gateway. Our manual plist also uses ai.openclaw.gateway — they share the same label. The disable-launchagent marker prevents OpenClaw from auto-installing its own plist and conflicting with ours.

Create the plist

Verify paths first:

which openclaw
which node
readlink -f $(which openclaw)
sudo tee /Library/LaunchDaemons/ai.openclaw.gateway.plist > /dev/null << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>ai.openclaw.gateway</string>
    <key>UserName</key>
    <string>openclaw</string>
    <key>GroupName</key>
    <string>staff</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>Umask</key>
    <integer>63</integer>
    <key>ProgramArguments</key>
    <array>
      <string>/opt/homebrew/bin/node</string>
      <string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
      <string>gateway</string>
      <string>--port</string>
      <string>18789</string>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/openclaw/.openclaw/logs/gateway.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/openclaw/.openclaw/logs/gateway.err.log</string>
    <key>EnvironmentVariables</key>
    <dict>
      <key>HOME</key>
      <string>/Users/openclaw</string>
      <key>OPENCLAW_HOME</key>
      <string>/Users/openclaw</string>
      <key>PATH</key>
      <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
      <key>OPENCLAW_GATEWAY_PORT</key>
      <string>18789</string>
      <key>DOCKER_HOST</key>
      <string>unix:///var/run/docker.sock</string>
      <key>OPENCLAW_GATEWAY_TOKEN</key>
      <string><!-- See Secrets Management section above --></string>
      <key>ANTHROPIC_API_KEY</key>
      <string><!-- See Secrets Management section above --></string>
      <key>BRAVE_API_KEY</key>
      <string><!-- See Secrets Management section above --></string>
      <key>GITHUB_TOKEN</key>
      <string><!-- See Secrets Management section above --></string>
      <key>OPENCLAW_SERVICE_MARKER</key>
      <string>openclaw</string>
      <key>OPENCLAW_SERVICE_KIND</key>
      <string>gateway</string>
    </dict>
  </dict>
</plist>
PLIST

Umask key: <integer>63</integer> is octal 077 — owner-only permissions for all gateway-created files (sessions, transcripts, etc.). Without this, npm upgrades can reset the process umask to the system default (022), making files group/world-readable. Added in OpenClaw 2026.3.2’s auto-generated plists; apply manually to existing plists.

If using Docker (OrbStack)

OrbStack runs in a user’s GUI session and exposes Docker via a user-specific socket (~/.orbstack/run/docker.sock). OrbStack’s privileged helper also creates a system-wide symlink at /var/run/docker.sock when OrbStack starts — this is what the LaunchDaemon uses via the DOCKER_HOST env var in the plist above. The socket is world-connectable (mode 755), so the openclaw user can reach it without any bootstrapping step.

Requirement: OrbStack must be running in some user’s GUI session before the gateway performs Docker operations. For reliable boot behavior, configure auto-login for the admin user so OrbStack starts automatically on boot.

Verify Docker is accessible from the openclaw user:

sudo -u openclaw docker ps

If this fails, OrbStack is not running yet. Start OrbStack from the admin account and retry.

Manage the daemon

# Start
sudo launchctl bootstrap system /Library/LaunchDaemons/ai.openclaw.gateway.plist

# Stop
sudo launchctl bootout system/ai.openclaw.gateway

# Restart
sudo launchctl bootout system/ai.openclaw.gateway && sleep 1 && \
  sudo launchctl bootstrap system /Library/LaunchDaemons/ai.openclaw.gateway.plist

# Check status
sudo launchctl print system/ai.openclaw.gateway 2>&1 | head -10

# Check it's listening
sudo lsof -i :18789

# View logs
tail -f /Users/openclaw/.openclaw/logs/gateway.log

Note: Use launchctl bootout/launchctl bootstrap (not openclaw gateway restart) — the openclaw gateway lifecycle commands target the gui/<uid> launchd domain, which does not exist for headless service accounts.

Config reload without restart

OpenClaw watches openclaw.json for changes automatically. The default reload mode is hybrid — safe changes (tool policies, agent definitions) are hot-applied, while critical changes trigger an in-process restart. No manual action needed for most config edits.

To force an immediate reload:

sudo kill -USR1 $(pgrep -f "openclaw.*gateway")

Use a full launchctl restart only for binary updates or when auto-reload doesn’t pick up your changes. Disable auto-reload with gateway.reload.mode: "off" if you prefer manual control.

Alternative: LaunchAgent (auto-login sessions only)

A LaunchAgent running in the openclaw user’s gui/<uid> domain gives openclaw gateway restart CLI compatibility and automatic OrbStack Docker socket access — but only if the user has an active GUI session. The gui/<uid> domain is created by loginwindow on GUI login and destroyed on logout; it cannot be bootstrapped for a headless service account that never logs in.

Use LaunchAgent when:

  • Running on a personal Mac where you’re always logged in, or
  • Inside a macOS VM with auto-login configured for the service user

Prerequisite: The openclaw user must be logged in via the GUI (auto-login or Fast User Switching without logging out). Logging out destroys the gui/<uid> domain and stops the service. Without auto-login, the service does not start at reboot — use LaunchDaemon instead.

Create LaunchAgents directory
sudo -u openclaw mkdir -p /Users/openclaw/Library/LaunchAgents
Create the plist

Verify paths first:

which openclaw
which node
readlink -f $(which openclaw)
sudo -u openclaw tee /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist > /dev/null << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>ai.openclaw.gateway</string>
    <key>Comment</key>
    <string>OpenClaw Gateway (Docker isolation)</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>Umask</key>
    <integer>63</integer>
    <key>ProgramArguments</key>
    <array>
      <string>/opt/homebrew/bin/node</string>
      <string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
      <string>gateway</string>
      <string>--port</string>
      <string>18789</string>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/openclaw/.openclaw/logs/gateway.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/openclaw/.openclaw/logs/gateway.err.log</string>
    <key>EnvironmentVariables</key>
    <dict>
      <key>HOME</key>
      <string>/Users/openclaw</string>
      <key>OPENCLAW_HOME</key>
      <string>/Users/openclaw</string>
      <key>PATH</key>
      <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
      <key>OPENCLAW_GATEWAY_PORT</key>
      <string>18789</string>
      <key>OPENCLAW_GATEWAY_TOKEN</key>
      <string><!-- See Secrets Management section above --></string>
      <key>ANTHROPIC_API_KEY</key>
      <string><!-- See Secrets Management section above --></string>
      <key>BRAVE_API_KEY</key>
      <string><!-- See Secrets Management section above --></string>
      <key>GITHUB_TOKEN</key>
      <string><!-- See Secrets Management section above --></string>
      <key>OPENCLAW_SERVICE_MARKER</key>
      <string>openclaw</string>
      <key>OPENCLAW_SERVICE_KIND</key>
      <string>gateway</string>
    </dict>
  </dict>
</plist>
PLIST
Bootstrap and manage the LaunchAgent

Get the openclaw user’s UID (needed for gui/ domain):

OPENCLAW_UID=$(id -u openclaw)

The user must be logged in via GUI before running this. If the gui/<uid> domain doesn’t exist yet (no active GUI session), bootstrap fails with error 125.

# Start (requires active GUI session for openclaw user)
sudo launchctl bootstrap gui/$OPENCLAW_UID /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Stop
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway

# Restart
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway
sudo launchctl bootstrap gui/$OPENCLAW_UID /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Check status
sudo launchctl print gui/$OPENCLAW_UID/ai.openclaw.gateway 2>&1 | head -10

openclaw gateway restart works in this mode — the LaunchAgent runs in the gui/<uid> domain that openclaw gateway targets.

Docker Sandboxing

Docker provides an additional isolation layer for agents.

VM isolation: macOS VMs — skip this section (no Docker inside macOS VMs). Linux VMs — follow this section (Docker works inside the VM).

  • macOS: OrbStack is recommended over Docker Desktop — lighter, faster, and integrates well with macOS networking.
  • Linux: Docker Engine (via apt/dnf) is all you need — no Docker Desktop required. See Docker Engine install docs .

Warning (bare Linux hosts): Adding a user to the docker group grants effective root access on the host. For bare-metal Linux deployments, consider rootless Docker or Podman as alternatives.

Build the sandbox image first

The default sandbox image (openclaw-sandbox:bookworm-slim) is not pre-built — it must be built locally before enabling sandboxing. Without this, exec tool calls inside the sandbox fail immediately (sh: 1: git: not found):

cd $(npm root -g)/openclaw
./scripts/sandbox-setup.sh

# Verify
docker run --rm openclaw-sandbox:bookworm-slim git --version

For agents that need additional tools (Node.js, Go, Rust, build tools), build the common image instead:

./scripts/sandbox-common-setup.sh   # → openclaw-sandbox-common:bookworm-slim

See Custom Sandbox Images for details on all available images and custom image builds.

{
  "agents": {
    "defaults": {
      "sandbox": {
        "mode": "non-main",
        "scope": "session",
        "workspaceAccess": "none"
      }
    }
  }
}

For stronger isolation, use an internal Docker network so agents can only reach the gateway:

# Internal network — no internet access from containers
docker network create --internal openclaw-sandbox

# Gateway joins both internal and external networks
docker network connect openclaw-sandbox gateway

Agents on the internal network can communicate with the gateway but have no route to the internet. This is particularly useful for the search agent — the gateway mediates all external access.

See OpenClaw sandboxing docs for full Docker configuration. For agents that need additional tools beyond the default image, see Custom Sandbox Images .

Sandbox the Main Agent

The recommended configuration sandboxes the main agent with Docker on an egress-allowlisted network. This roots main’s filesystem inside Docker for channel sessions while preserving workspace access, and restricts outbound traffic to pre-approved hosts.

{
  "agents": {
    "list": [{
      "id": "main",
      // ... other config ...
      "sandbox": {
        "mode": "non-main",
        "scope": "agent",
        "workspaceAccess": "rw",
        "docker": { "network": "openclaw-egress" }
      }
    }]
  }
}

mode: "non-main" sandboxes all channel sessions (WhatsApp DMs, groups, cron runs) in Docker while leaving the operator’s Control UI / HTTP API session (agent:main:main) unsandboxed on the host. This matches the threat model: channel sessions receive untrusted external input and should be sandboxed; the Control UI is the trusted operator interface and benefits from direct host access (cron management, service restarts, workspace tasks). Sandboxed sessions can no longer read openclaw.json or auth-profiles.json. Workspace data (SOUL.md, memory, workspace files) remains accessible via the mount. Outbound network is restricted to pre-approved hosts via the openclaw-egress Docker network and host-level firewall rules.

Prerequisites:

  1. Docker network created: docker network create openclaw-egress
  2. Egress allowlist configured — see scripts/network-egress/ for setup

macOS with Docker Desktop or OrbStack: Egress allowlisting via pf rules does not work — these tools run containers inside a Linux VM where the bridge interface is inaccessible to host-level pf. Options: (1) use a Linux VM deployment with apply-rules-linux.sh inside the VM, (2) use colima with bridged networking, or (3) accept no egress filtering and rely on tool policy as the primary defense.

Trade-off: Sandboxed channel sessions (WhatsApp DMs, groups, cron) run inside Docker — host-native tools (Xcode, Homebrew binaries) are unavailable there. The Control UI session runs on host and has full access. For an even more isolated architecture with a dedicated computer agent, see Hardened Multi-Agent .

Multi-Gateway Options

For running multiple gateway instances — profiles, multi-user separation, or VM variants — see Multi-Gateway Deployments .


VM Isolation

Run OpenClaw inside a VM for kernel-level host isolation. Your host is untouched — no access to personal files, external drives, or other host resources. Two sub-variants: macOS VMs (macOS hosts) and Linux VMs (any host).

VM Isolation: macOS VMs

macOS hosts only.

Run OpenClaw inside a macOS VM. Your host macOS is untouched.

macOS Host (personal use, untouched)
  └── VM — "openclaw-vm"
       └── openclaw user (standard, non-admin)
            └── Gateway (port 18789): main + search (+ optional channel agents)

Same multi-agent architecture as Docker isolation (main + search, plus optional channel agents, sessions_send delegation), but with a VM boundary instead of an OS user boundary. No Docker inside the VM (macOS doesn’t support nested virtualization). For adding macOS-native tooling (Xcode, iOS Simulator, macOS apps) via Lume VMs, see Phase 8: Computer Use .

Two hypervisor options:

LumeParallels
CostFree~$100/yr
InterfaceCLI-onlyGUI + CLI (prlctl)
HypervisorApple Virtualization.frameworkOwn hypervisor
macOS VM limit2 per host (Apple’s limit)2 per host (same limit)
Best forHeadless/server deploymentsGUI management, advanced snapshots

Install

Lume:

brew install --cask lume

Parallels:

brew install --cask parallels

Or download from parallels.com .

Create the VM

Lume:

lume create openclaw-vm --os macos --ipsw latest \
  --cpu 8 --memory 16384 --disk-size 100 --unattended

Parallels — create via GUI (File > New > Download macOS, recommended) or CLI:

prlctl create openclaw-vm --ostype macos
prlctl set openclaw-vm --cpus 8 --memsize 16384

The CLI creates an empty VM shell — you still need to install macOS (attach an IPSW or use the GUI installer). The GUI workflow handles this automatically.

Resource guidance:

  • CPU 8 — adjust based on your machine (leave cores for the host)
  • Memory 16GB — minimum 8GB recommended for OpenClaw
  • Disk 100GB — Lume uses sparse disks (grow on demand); Parallels: configure in VM settings

Start and connect

Lume:

lume run openclaw-vm --no-display
# Wait for boot, then SSH in:
lume ssh openclaw-vm

Parallels:

prlctl start openclaw-vm
# SSH in (enable Remote Login in VM's System Settings first):
ssh user@$(prlctl exec openclaw-vm ipconfig getifaddr en0)

Inside the VM: install dependencies

Same regardless of hypervisor:

# Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Install Node.js
brew install node

# Install Xcode Command Line Tools (for coding agents)
xcode-select --install

Dedicated user (inside VM)

The default VM user is typically admin with a known password (e.g., Lume creates lume/lume). A compromised agent with exec access could escalate via echo <password> | sudo -S <command>. Create a dedicated standard (non-admin) user to run OpenClaw:

Why not just use the default user? Three reasons: (1) admin users can sudo — a compromised agent could gain root access to the VM, (2) the dedicated user has no interactive shell sessions — auto-login creates the gui/<uid> domain for the LaunchAgent but RC files (.zshrc) are never sourced, so environment injection is neutralized, (3) a non-default username is less predictable to an attacker enumerating the system.

# Create a standard user (NOT admin — no sudo access)
sudo sysadminctl -addUser openclaw -fullName "OpenClaw" -password "<temp>" \
  -home /Users/openclaw -shell /bin/zsh
sudo passwd openclaw

# Create home directory if not auto-created
sudo mkdir -p /Users/openclaw
sudo chown -R openclaw:staff /Users/openclaw

Keep the default admin user for management. You need an admin user to SSH in, modify plists, restart services, and install updates. The dedicated openclaw user only runs the gateway. Change the default admin password (sudo passwd lume or equivalent) — the default is well-known and a compromised agent could use it for local SSH escalation.

Install OpenClaw

If already installed globally (e.g., by the admin user), skip the install and verify access:

sudo -u openclaw openclaw --version

Otherwise:

sudo -u openclaw bash -c 'curl -fsSL https://openclaw.ai/install.sh | bash'
sudo -u openclaw openclaw --version
sudo -u openclaw openclaw doctor

Download and review the script before running, or verify the source URL.

Then either migrate from your personal user or create a fresh config:

# Fresh install (skip if migrating)
sudo -u openclaw openclaw setup
Required config: gateway.mode

The gateway refuses to start unless gateway.mode is set in openclaw.json:

{
  "gateway": {
    "mode": "local"
  }
}

Run openclaw doctor to diagnose startup failures.

Log directory
sudo -u openclaw mkdir -p /Users/openclaw/.openclaw/logs

The gateway auto-creates agent, workspace, and session directories on startup. The log directory must exist before the service starts.

File permissions

After the first successful gateway start, set ownership and lock down permissions:

# Set ownership first (critical — setup/copy commands run as root)
sudo chown -R openclaw:staff /Users/openclaw/.openclaw

# Then restrict permissions
sudo chmod 700 /Users/openclaw
sudo chmod 700 /Users/openclaw/.openclaw
sudo chmod 600 /Users/openclaw/.openclaw/openclaw.json
sudo chmod 600 /Users/openclaw/.openclaw/credentials/*.json
sudo chmod 600 /Users/openclaw/.openclaw/agents/*/agent/auth-profiles.json
sudo chmod 600 /Users/openclaw/.openclaw/identity/*.json
sudo chmod -R 600 /Users/openclaw/.openclaw/credentials/whatsapp/default/*
sudo chmod 700 /Users/openclaw/.openclaw/credentials/whatsapp

Then follow Phase 4 and Phase 5 to configure the multi-agent gateway. Use examples/openclaw.json as a starting point.

LaunchAgent (Inside VM)

A LaunchAgent in the openclaw user’s gui/<uid> domain. With auto-login configured for the openclaw user, loginwindow creates the gui/<uid> domain automatically at boot — no manual bootstrap required.

Prerequisite: Auto-login must be configured for the openclaw user (System Settings → Users & Groups → automatically log in as openclaw). Without auto-login, the gui/<uid> domain doesn’t exist after a reboot and the service doesn’t start.

Create LaunchAgents directory
sudo -u openclaw mkdir -p /Users/openclaw/Library/LaunchAgents
Create the plist

Verify paths first:

which openclaw
which node
readlink -f $(which openclaw)
sudo -u openclaw tee /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist > /dev/null << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>ai.openclaw.gateway</string>
    <key>Comment</key>
    <string>OpenClaw Gateway (macOS VM)</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>Umask</key>
    <integer>63</integer>
    <key>ProgramArguments</key>
    <array>
      <string>/opt/homebrew/bin/node</string>
      <string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
      <string>gateway</string>
      <string>--port</string>
      <string>18789</string>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/openclaw/.openclaw/logs/gateway.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/openclaw/.openclaw/logs/gateway.err.log</string>
    <key>EnvironmentVariables</key>
    <dict>
      <key>HOME</key>
      <string>/Users/openclaw</string>
      <key>OPENCLAW_HOME</key>
      <string>/Users/openclaw</string>
      <key>PATH</key>
      <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
      <key>OPENCLAW_GATEWAY_PORT</key>
      <string>18789</string>
      <key>OPENCLAW_GATEWAY_TOKEN</key>
      <string><!-- See Secrets Management section above --></string>
      <key>ANTHROPIC_API_KEY</key>
      <string><!-- See Secrets Management section above --></string>
      <key>BRAVE_API_KEY</key>
      <string><!-- See Secrets Management section above --></string>
      <key>GITHUB_TOKEN</key>
      <string><!-- See Secrets Management section above --></string>
      <key>OPENCLAW_SERVICE_MARKER</key>
      <string>openclaw</string>
      <key>OPENCLAW_SERVICE_KIND</key>
      <string>gateway</string>
    </dict>
  </dict>
</plist>
PLIST
Bootstrap and manage
OPENCLAW_UID=$(id -u openclaw)

# Start (bootstrap into the user's GUI domain)
sudo launchctl bootstrap gui/$OPENCLAW_UID /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Stop
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway

# Restart
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway
sudo launchctl bootstrap gui/$OPENCLAW_UID /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Check status
sudo launchctl print gui/$OPENCLAW_UID/ai.openclaw.gateway 2>&1 | head -10

# Check it's listening
sudo lsof -i :18789

# View logs
tail -f /Users/openclaw/.openclaw/logs/gateway.log
Hardened alternative: LaunchDaemon

For stronger isolation inside the VM, use a LaunchDaemon instead — the openclaw user has no gui/<uid> domain, blocking agent persistence via LaunchAgents, login items, or shell RC files. See the Docker isolation section’s macOS: LaunchDaemon for the plist format (already includes UserName/GroupName keys; place in /Library/LaunchDaemons/, use system domain for launchctl commands).

Trade-off: The VM boundary is the real isolation layer — the openclaw user’s writable ~/Library/LaunchAgents/ is acceptable risk since a compromised VM doesn’t affect the host. Use LaunchDaemon only when you want defense-in-depth within the VM.

VM management

Lume:

lume clone openclaw-vm openclaw-vm-backup   # Snapshot
lume stop openclaw-vm                        # Stop
lume run openclaw-vm --shared-dir ~/shared:~/shared  # Share files
lume list                                    # List VMs

Parallels:

prlctl snapshot openclaw-vm -n "pre-update"  # Snapshot
prlctl stop openclaw-vm                      # Stop
# Share files: configure via GUI (Parallels Tools > Sharing)
prlctl list                                  # List VMs

Auto-start after host reboot: Neither hypervisor auto-starts VMs by default. For Lume, create a LaunchDaemon on the host that runs lume run openclaw-vm --no-display. For Parallels, enable Start Automatically in VM settings, or use prlctl start openclaw-vm in a LaunchDaemon.

Config reload

OpenClaw watches openclaw.json for changes automatically — same behavior as Docker isolation. Safe changes (tool policies, agent definitions) are hot-applied; critical changes trigger an in-process restart. Force a reload with sudo kill -USR1 $(pgrep -f "openclaw.*gateway").

Key differences from Docker isolation

  • No Dockersandbox blocks in openclaw.json have no effect. Tool policy + SOUL.md provide internal isolation. The read→exfiltrate chain is open within the VM (channel agents can read ~/.openclaw/openclaw.json), but only OpenClaw data is at risk.
  • Standard user — the openclaw user has no sudo access. Even within the VM, privilege escalation is blocked.
  • No interactive login — the openclaw user never logs in interactively. Auto-login creates the gui/<uid> domain for the LaunchAgent automatically at boot, but shell RC files (.zshrc) are never sourced in non-interactive sessions. For defense-in-depth against LaunchAgent persistence within the VM, use the hardened LaunchDaemon alternative.
  • VM is the outer boundary — your host macOS is untouched. A full compromise of the VM doesn’t affect the host.

For channel separation with two macOS VMs, see Multi-Gateway: VM Variants .

VM Isolation: Linux VMs

Works on macOS and Linux hosts. Combines VM host boundary with Docker sandbox inside.

Run OpenClaw inside a Linux VM with Docker. This gives the strongest combined isolation posture — kernel-level VM boundary from the host, plus Docker sandboxing for internal agent isolation.

Host (macOS or Linux, untouched)
  └── Linux VM — "openclaw-vm"
       └── openclaw user (no sudo, docker group)
            └── Gateway (port 18789): main + search (+ optional channel agents)
                 ├── main (Docker sandbox, egress-allowlisted network)
                 └── search (unsandboxed — no filesystem/exec tools)

Same multi-agent architecture as Docker isolation, but running inside a VM. Docker closes the read→exfiltrate chain; the VM boundary protects the host. No macOS 2-VM limit — run as many Linux VMs as resources allow.

Hypervisor options

MultipassUTMKVM/libvirt
Host OSmacOS or LinuxmacOS onlyLinux only
InterfaceCLIGUI + CLICLI (virsh)
Best forHeadless/server (recommended)macOS users wanting GUILinux-native deployments
Installbrew install multipass / snap install multipassbrew install --cask utmapt install qemu-kvm libvirt-daemon-system

Create the VM

Multipass (recommended):

multipass launch --name openclaw-vm --cpus 4 --memory 4G --disk 40G
multipass shell openclaw-vm

KVM/libvirt (Linux hosts):

# Download Ubuntu Server ISO first
curl -LO https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso

virt-install --name openclaw-vm --os-variant ubuntu24.04 \
  --vcpus 4 --memory 4096 --disk size=40 \
  --cdrom ubuntu-24.04-live-server-amd64.iso

For headless (no GUI) installs, use autoinstall with a cloud-init seed instead of --cdrom.

Resource guidance:

  • CPU 4 — adjust based on your host (leave cores for host workloads)
  • Memory 4GB — sufficient for headless Linux + Node.js + Docker. Increase to 8GB if agents do heavy coding
  • Disk 40GB — sparse/thin-provisioned by default on most hypervisors

Inside the VM: install dependencies

# Update packages
sudo apt update && sudo apt upgrade -y

# Install Node.js (LTS via NodeSource)
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs

# Install Docker
curl -fsSL https://get.docker.com | sudo sh

# Install OpenClaw
curl -fsSL https://openclaw.ai/install.sh | bash
openclaw --version

For production, verify GPG signatures per each project’s install docs rather than piping to shell.

Dedicated user (inside Linux VM)

Create a non-sudo user with Docker access:

sudo useradd -m -s /bin/bash openclaw
sudo passwd openclaw
sudo usermod -aG docker openclaw

Why docker group? The openclaw user needs Docker socket access for sandboxing but has no sudo. Note: docker group grants root-equivalent access to the Docker daemon — but the VM boundary contains this. Even if an attacker escapes Docker to VM root, they’re still inside the VM, not on your host.

Install OpenClaw as dedicated user

If already installed globally (e.g., by the admin user), skip the install and verify access:

sudo -u openclaw openclaw --version

Otherwise:

sudo -u openclaw bash -c 'curl -fsSL https://openclaw.ai/install.sh | bash'
sudo -u openclaw openclaw --version
sudo -u openclaw openclaw doctor

# Fresh install (skip if migrating)
sudo -u openclaw openclaw setup

Download and review the script before running, or verify the source URL.

Required config: gateway.mode

Same as all other models — the gateway refuses to start without it:

{
  "gateway": {
    "mode": "local"
  }
}

Log directory and permissions

sudo -u openclaw mkdir -p /home/openclaw/.openclaw/logs

# After first successful gateway start:
# Set ownership first (critical — setup/copy commands run as root)
sudo chown -R openclaw:openclaw /home/openclaw/.openclaw

# Then restrict permissions
sudo chmod 700 /home/openclaw
sudo chmod 700 /home/openclaw/.openclaw
sudo chmod 600 /home/openclaw/.openclaw/openclaw.json
sudo chmod 600 /home/openclaw/.openclaw/credentials/*.json
sudo chmod 600 /home/openclaw/.openclaw/agents/*/agent/auth-profiles.json
sudo chmod 600 /home/openclaw/.openclaw/identity/*.json

Service: systemd

Use the Linux: systemd section below — it applies identically inside a Linux VM. The openclaw user is already set up with Docker access.

Key differences from Docker isolation

  • VM is the outer boundary — your host is untouched. A full compromise of the VM doesn’t affect the host.
  • Docker works insidesandbox blocks in openclaw.json work normally. Both isolation chains (VM + Docker) are active.
  • No macOS tooling — Xcode, Homebrew-native macOS tools, and Swift aren’t available. Use macOS VMs if agents need these.
  • Lighter weight — a headless Ubuntu VM uses 2-4GB RAM vs 8-16GB for a macOS VM.

Firewall and Tailscale

Same configuration as Docker isolation — see macOS Firewall and Tailscale ACLs . Apply inside the Linux VM (UFW/iptables) and optionally install Tailscale inside the VM for remote access.

For multiple Linux VMs, see Multi-Gateway: VM Variants .


Service Management Comparison

LaunchDaemon (macOS)LaunchAgent (macOS, auto-login)systemd (Linux)
Runs asDedicated user (UserName key)Dedicated userDedicated user
Starts atBoot — no GUI session requiredBoot (requires GUI auto-login configured)Boot
openclaw gateway compatNo (use launchctl instead)YesN/A
OrbStack DockerVia /var/run/docker.sock (OrbStack must run)Automatic — same user sessionN/A
SecurityStrongest (macOS)StandardStrongest (Linux)

Linux: systemd

Applies to: Docker isolation on Linux hosts and inside Linux VMs (same systemd unit, same user setup).

Prerequisite: Create the secrets file first — see Secrets Management below — before enabling this unit.

Create the service file:

sudo tee /etc/systemd/system/openclaw-gateway.service > /dev/null << 'EOF'
[Unit]
Description=OpenClaw Gateway
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=openclaw
Group=openclaw
ExecStart=/usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789
Restart=always
RestartSec=5

# Hardening
NoNewPrivileges=true
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/home/openclaw/.openclaw

Environment=HOME=/home/openclaw
Environment=OPENCLAW_HOME=/home/openclaw
Environment=OPENCLAW_GATEWAY_PORT=18789
EnvironmentFile=/etc/openclaw/secrets.env

StandardOutput=append:/home/openclaw/.openclaw/logs/gateway.log
StandardError=append:/home/openclaw/.openclaw/logs/gateway.err.log

[Install]
WantedBy=multi-user.target
EOF

Verify your Node.js path: which node && readlink -f $(which node). Update ExecStart if your path differs (e.g., nvm/asdf installs use ~/.nvm/ or ~/.asdf/ paths, not /usr/bin/node).

Manage the service:

# Enable + start
sudo systemctl enable --now openclaw-gateway

# Stop
sudo systemctl stop openclaw-gateway

# Restart
sudo systemctl restart openclaw-gateway

# Status
sudo systemctl status openclaw-gateway

# Logs
sudo journalctl -u openclaw-gateway -f

VM isolation: secrets via SSH

macOS VMs: Push secrets from the host to the VM’s LaunchAgent plist (or LaunchDaemon if using the hardened alternative). Uses lume ssh — for Parallels, replace with prlctl exec or regular ssh user@<vm-ip>.

Linux VMs: Push secrets to the systemd environment file inside the VM:

# Multipass
multipass exec openclaw-vm -- sudo tee /etc/openclaw/secrets.env > /dev/null << 'EOF'
OPENCLAW_GATEWAY_TOKEN=your-gateway-token
ANTHROPIC_API_KEY=sk-ant-...
BRAVE_API_KEY=BSA...
GITHUB_TOKEN=github_pat_...
EOF
multipass exec openclaw-vm -- sudo chmod 600 /etc/openclaw/secrets.env
multipass exec openclaw-vm -- sudo systemctl restart openclaw-gateway

# Or via SSH (any hypervisor)
ssh user@<vm-ip> 'sudo tee /etc/openclaw/secrets.env > /dev/null' < secrets.env
ssh user@<vm-ip> 'sudo chmod 600 /etc/openclaw/secrets.env && sudo systemctl restart openclaw-gateway'

Single VM (LaunchAgent — default):

OC_UID=$(lume ssh openclaw-vm -- id -u openclaw)
PLIST=/Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Set a secret
lume ssh openclaw-vm -- sudo -u openclaw /usr/libexec/PlistBuddy \
  -c "Set :EnvironmentVariables:ANTHROPIC_API_KEY sk-ant-..." "$PLIST"

# Restart the service
lume ssh openclaw-vm -- sudo launchctl bootout "gui/$OC_UID/ai.openclaw.gateway" 2>/dev/null
lume ssh openclaw-vm -- sudo launchctl bootstrap "gui/$OC_UID" "$PLIST"

# Lock down the plist
lume ssh openclaw-vm -- chmod 600 "$PLIST"

Single VM (LaunchDaemon — hardened alternative):

PLIST=/Library/LaunchDaemons/ai.openclaw.gateway.plist

# Set a secret
lume ssh openclaw-vm -- sudo /usr/libexec/PlistBuddy \
  -c "Set :EnvironmentVariables:ANTHROPIC_API_KEY sk-ant-..." "$PLIST"

# Restart the daemon
lume ssh openclaw-vm -- sudo launchctl bootout system/ai.openclaw.gateway 2>/dev/null
lume ssh openclaw-vm -- sudo launchctl bootstrap system "$PLIST"

# Lock down the plist
lume ssh openclaw-vm -- sudo chmod 600 "$PLIST"

For multi-VM secrets automation, see Multi-Gateway: VM Variants .

Docker isolation: Single plist

No deploy script needed — one LaunchAgent (or LaunchDaemon) plist holds all secrets. Lock it down:

# LaunchAgent (default)
sudo chmod 600 /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Or LaunchDaemon (if using the hardened alternative)
sudo chmod 600 /Library/LaunchDaemons/ai.openclaw.gateway.plist

Linux: Environment file

The systemd unit references EnvironmentFile instead of inline secrets:

sudo mkdir -p /etc/openclaw
sudo tee /etc/openclaw/secrets.env > /dev/null << 'EOF'
OPENCLAW_GATEWAY_TOKEN=your-gateway-token
ANTHROPIC_API_KEY=sk-ant-...
BRAVE_API_KEY=BSA...
GITHUB_TOKEN=github_pat_...
# channel-guard and content-guard both require OPENROUTER_API_KEY
EOF
sudo chmod 600 /etc/openclaw/secrets.env
sudo chown root:root /etc/openclaw/secrets.env

What stays in openclaw.json

Channel config (allowFrom, dmPolicy), agent definitions, tool policies, workspace paths — structural config, not secrets. Channel credentials (WhatsApp session, Signal auth) are managed by their plugins in ~/.openclaw/credentials/.


macOS Firewall

sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on

Also disable unneeded sharing services in System Settings > General > Sharing:

  • Remote Management
  • Screen Sharing (unless used for remote access)
  • File Sharing
  • AirDrop

Linux (ufw):

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable

Tailscale ACLs

If using Tailscale, configure ACLs to prevent the OpenClaw machine from initiating connections to other devices on your tailnet. This blocks lateral movement if the agent is compromised. Internet traffic (APIs, WhatsApp, Signal) is unaffected — Tailscale ACLs only control tailnet traffic.

Tag the device

Add to your Tailscale ACL config at https://login.tailscale.com/admin/acls :

{
  "tagOwners": {
    "tag:openclaw": ["autogroup:admin"]
  }
}

Then tag the machine in the Tailscale admin console under Machines.

ACL rules

{
  "grants": [
    {
      "src": ["autogroup:member"],
      "dst": ["*"],
      "ip": ["*"]
    }
  ],

  "ssh": [
    {
      "action": "check",
      "src": ["autogroup:member"],
      "dst": ["autogroup:self"],
      "users": ["autogroup:nonroot", "root"]
    },
    {
      "action": "check",
      "src": ["autogroup:member"],
      "dst": ["tag:openclaw"],
      "users": ["autogroup:nonroot", "root"]
    }
  ]
}

The key: tag:openclaw has no outbound grant — it can’t reach other tailnet devices. But all your personal devices (autogroup:member) can reach it (including via SSH).

Important: Test SSH/screen sharing still works after applying ACLs. If locked out, use physical access (or out-of-band management) to fix.

If You Need LAN Access

Prefer Tailscale Serve or an SSH tunnel over binding to 0.0.0.0. If LAN binding is unavoidable:

  1. Set authgateway.auth.mode: "token" or "password" (required for non-loopback; gateway enforces this)

  2. Firewall to source IPs — restrict port 18789 to specific trusted IPs:

    macOS (pf):

    # /etc/pf.conf — allow only your admin machine
    block in on en0 proto tcp to any port 18789
    pass in on en0 proto tcp from 192.168.1.100 to any port 18789

    Linux (ufw):

    sudo ufw deny in on eth0 to any port 18789
    sudo ufw allow in on eth0 from 192.168.1.100 to any port 18789
  3. Never port-forward broadly — don’t expose 18789 on your router

What’s exposed on port 18789: Control UI, WebSocket protocol, HTTP API (/v1/chat/completions), and all webhook endpoints. Binding to 0.0.0.0 without a source-IP firewall exposes all of these to every device on your network.

Reverse Proxy Configuration

If terminating TLS with a reverse proxy (Caddy, nginx, Cloudflare Tunnel):

  1. Set trustedProxies in openclaw.json:

    { "gateway": { "trustedProxies": ["127.0.0.1"] } }
  2. Proxy must OVERWRITE X-Forwarded-For — not append. Appending allows clients to spoof their IP.

    Caddy (overwrites by default — no action needed).

    nginx:

    proxy_set_header X-Forwarded-For $remote_addr;  # overwrites, not appends
  3. Strip Tailscale identity headers if gateway.auth.allowTailscale is enabled:

    proxy_set_header Tailscale-User-Login "";
    proxy_set_header Tailscale-User-Name "";

    Forwarding these headers from your proxy allows authentication bypass.


macOS Companion App

If you install the OpenClaw macOS app (Docker isolation — on host), create this marker file before installing to prevent it from starting its own gateway:

sudo -u openclaw touch /Users/openclaw/.openclaw/disable-launchagent

The app will attach to the existing gateway in read-only mode.

Alternative (per-launch): open -a OpenClaw --args --attach-only

VM isolation: The companion app runs on your host macOS. Recommended: use Tailscale Serve inside the VM (tailscale serve --bg --https 8443 http://127.0.0.1:18789) and connect via --gateway-url https://<tailscale-ip>:8443. Alternative: SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@<vm-ip>). Avoid binding the gateway to 0.0.0.0 — see If You Need LAN Access .


Signal Setup

Signal device links are host-specific. If migrating to new hardware, you’ll need to re-pair (see Phase 7: Migration ).

Signal requires signal-cli (Java-based) linked as a device.

Prerequisite: Signal requires Java 21+. Install via brew install openjdk@21 (macOS) or your distro’s package manager (Linux, e.g., sudo apt install openjdk-21-jre).

Install

macOS:

brew install signal-cli   # requires Java 21

Linux (Debian/Ubuntu):

sudo apt-get install -y default-jre
# Check https://github.com/AsamK/signal-cli/releases for latest version
SIGNAL_CLI_VERSION=0.13.12  # Update to latest
curl -L -o signal-cli.tar.gz \
  "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux.tar.gz"
sudo tar xf signal-cli.tar.gz -C /opt
sudo ln -sf /opt/signal-cli-${SIGNAL_CLI_VERSION}/bin/signal-cli /usr/local/bin/signal-cli

Link to Signal account

Run as the openclaw user so credentials are stored in its home:

sudo -u openclaw signal-cli link -n "OpenClaw"

This outputs a URI. Generate a QR code to scan:

pip install qrcode pillow
python3 -c "
import qrcode
img = qrcode.make('sgnl://linkdevice?uuid=...&pub_key=...')
img.save('signal-link-qr.png')
"
open signal-link-qr.png    # macOS
# xdg-open signal-link-qr.png  # Linux

Scan with Signal > Settings > Linked Devices > Link New Device. Do this quickly — links expire fast.

Configure Signal channel

Add to openclaw.json:

{
  "channels": {
    "signal": {
      "enabled": true,
      "account": "+46XXXXXXXXX",
      "cliPath": "signal-cli",
      "dmPolicy": "pairing",
      "allowFrom": ["+46XXXXXXXXX"],
      "groupPolicy": "allowlist",
      "groups": {
        "*": { "requireMention": false }
      },
      "mediaMaxMb": 8
    }
  }
}

Signal groups (2026.3.13-1+): The groups config key is now supported in Signal’s channel schema. Configure groups exactly as you would for WhatsApp — use group IDs as keys or "*" for all groups. Note: Signal has no native @mention support, so set requireMention: false and use mentionPatterns in the agent’s groupChat config instead.

Slow JVM starts: signal-cli is Java-based and can take 10–30s to start. If you manage the daemon separately, set autoStart: false and point httpUrl at your running instance (e.g., "httpUrl": "http://127.0.0.1:8080"). For auto-spawned daemons, increase startupTimeoutMs if you see timeouts.

Approve senders

openclaw pairing list signal
openclaw pairing approve signal <CODE>

Note: The primary Signal device receives all messages too. Use a dedicated phone number for the bot, or mute notifications on the primary device.


Verification Checklist

Config validation is strict. OpenClaw rejects unknown keys, malformed types, or invalid values — the gateway refuses to start. If the service starts but exits immediately, run openclaw doctor to diagnose. Use openclaw doctor --fix to auto-apply safe repairs.

After deployment, verify everything works:

# Service is running
sudo launchctl print gui/$(id -u openclaw)/ai.openclaw.gateway 2>&1 | head -10  # macOS (LaunchAgent)
sudo launchctl print system/ai.openclaw.gateway 2>&1 | head -10                # macOS (LaunchDaemon, hardened)
sudo systemctl status openclaw-gateway                            # Linux

# Gateway is listening
sudo lsof -i :18789

# Health check (replace <token> with your OPENCLAW_GATEWAY_TOKEN value)
curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:18789/health

# Recent logs
tail -20 /Users/openclaw/.openclaw/logs/gateway.log    # macOS (VM / Docker isolation)
tail -20 /home/openclaw/.openclaw/logs/gateway.log     # Linux
  • Gateway starts at boot (both options)
  • Health endpoint responds
  • WhatsApp/Signal messages get responses
  • Logs are written to the expected location
  • File permissions are 600/700 on sensitive files
  • Gateway only listens on loopback
  • Tailscale ACLs block outbound from openclaw machine (if applicable)
  • Security audit passes: openclaw security audit --deep

Ongoing Management

# Edit config (VM and Docker isolation use the same dedicated user)
sudo -u openclaw vi /Users/openclaw/.openclaw/openclaw.json

# Restart (macOS — LaunchDaemon, Docker isolation)
sudo launchctl bootout system/ai.openclaw.gateway && sleep 1 && \
  sudo launchctl bootstrap system /Library/LaunchDaemons/ai.openclaw.gateway.plist

# Restart (macOS — LaunchAgent, VM with auto-login)
OPENCLAW_UID=$(id -u openclaw)
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway
sudo launchctl bootstrap gui/$OPENCLAW_UID /Users/openclaw/Library/LaunchAgents/ai.openclaw.gateway.plist

# Restart (Linux)
sudo systemctl restart openclaw-gateway

Updating OpenClaw

# Backup before upgrading
sudo cp -r /Users/openclaw/.openclaw /Users/openclaw/.openclaw.bak

# Update (either method works)
openclaw update                                  # Built-in updater
# or: curl -fsSL https://openclaw.ai/install.sh | bash

# Verify
openclaw --version
openclaw doctor

# Restart the daemon (see commands above)

If something breaks, restore the backup. Run openclaw doctor --fix to apply any config migrations needed after the update.

Log Rotation

Gateway logs grow indefinitely. Set up rotation:

macOS — add to /etc/newsyslog.d/openclaw.conf:

/Users/openclaw/.openclaw/logs/gateway.log     openclaw:staff  640  7  1024  *  J
/Users/openclaw/.openclaw/logs/gateway.err.log openclaw:staff  640  7  1024  *  J

640 restricts log access to owner and group only. Gateway logs may contain sensitive data.

Linux — add to /etc/logrotate.d/openclaw:

/home/openclaw/.openclaw/logs/*.log {
    weekly
    rotate 4
    compress
    missingok
    notifempty
}

Session Transcript Pruning

Session files (agents/<id>/sessions/*.jsonl) contain full message history including tool output. Prune old sessions periodically:

Test before scheduling. Run the find command without -delete first to verify what would be pruned:

sudo -u openclaw find /Users/openclaw/.openclaw/agents/*/sessions -name "*.jsonl" -mtime +30

Delete sessions older than 30 days:

sudo -u openclaw find /Users/openclaw/.openclaw/agents/*/sessions -name "*.jsonl" -type f -mtime +30 -delete

Schedule with cron (simplest):

macOS:

# Add to openclaw user's crontab
sudo -u openclaw crontab -e
# Add line (weekly, Sunday at 3am):
0 3 * * 0 find /Users/openclaw/.openclaw/agents/*/sessions -name "*.jsonl" -type f -mtime +30 -delete

Linux:

# Add to openclaw user's crontab
sudo crontab -u openclaw -e
# Add line (weekly, Sunday at 3am):
0 3 * * 0 find /home/openclaw/.openclaw/agents/*/sessions -name "*.jsonl" -type f -mtime +30 -delete

macOS LaunchDaemon alternative (more Mac-native):

<!-- /Library/LaunchDaemons/com.openclaw.session-pruning.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.openclaw.session-pruning</string>
    <key>UserName</key>
    <string>openclaw</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/sh</string>
        <string>-c</string>
        <string>find /Users/openclaw/.openclaw/agents/*/sessions -name "*.jsonl" -type f -mtime +30 -delete</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Weekday</key>
        <integer>0</integer>
        <key>Hour</key>
        <integer>3</integer>
    </dict>
</dict>
</plist>

Load the LaunchDaemon:

sudo launchctl bootstrap system /Library/LaunchDaemons/com.openclaw.session-pruning.plist

Retention policy: 30 days balances audit trail (ability to review recent sessions) with storage (transcript files can be large). Adjust mtime value based on your needs.


Deployment Gotchas

Operational issues discovered during real deployments. Most are macOS-specific.

macOS Service User Setup

  • sysadminctl doesn’t create home directoriessysadminctl -addUser assigns a home path but doesn’t create it. After creating the user: sudo mkdir -p /Users/openclaw && sudo chown openclaw:staff /Users/openclaw
  • Home dir ownershipsudo mkdir -p creates directories owned by root. Always chown user:staff explicitly after.
  • Admin access to dedicated user files — dedicated user home dirs are drwx------. Use macOS ACLs for admin read/write access:
    # Traverse-only on home dir (minimal — just enough to reach .openclaw)
    sudo chmod +a "youradmin allow list,search,execute" /Users/openclaw
    
    # Full read+write with inheritance on .openclaw
    sudo chmod -R +a "youradmin allow \
      read,write,append,delete,add_file,add_subdirectory,delete_child,\
      readattr,writeattr,readextattr,writeextattr,readsecurity,\
      list,search,execute,\
      file_inherit,directory_inherit" \
      /Users/openclaw/.openclaw
  • NOPASSWD sudo — automated setup tools may need NOPASSWD in /etc/sudoers.d/. Remove immediately after setup: sudo rm /etc/sudoers.d/<file>

Running Commands as Service User

sudo -u preserves the caller’s working directory. Simple commands (--version, --help) typically work, but commands that access the filesystem can fail if the current directory isn’t accessible to the target user:

# Works for simple commands
sudo -u openclaw openclaw --version

# Required for commands that access files or the working directory
sudo -u openclaw bash -c 'cd /Users/openclaw && HOME=/Users/openclaw openclaw doctor'

Use the bash -c pattern for interactive setup, openclaw doctor, openclaw setup, or any command that reads/writes files.

Docker/OrbStack

  • OrbStack docker CLI not in PATH — OrbStack installs at /usr/local/bin/docker, which may not be in PATH for dedicated users or non-interactive shells. Use the full path, ensure the engine is running with orbctl start, or symlink: sudo ln -sf /Applications/OrbStack.app/Contents/MacOS/orbctl /usr/local/bin/docker

Playwright

  • Per-user install requires correct environmentnpx -y playwright install chromium as another user needs HOME and PATH set correctly, and must cd to the user’s home first. The npm cache must be writable by that user.

Signal

  • JAVA_HOME stale after brew upgrade — signal-cli needs Java 21. After brew upgrades, JAVA_HOME may point to a removed version. Set explicitly in plist EnvironmentVariables: JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/<version>/libexec/openjdk.jdk/Contents/Home

Migration Between Hosts

For a complete migration guide covering config, credentials, memory, channels, services, and scheduled tasks, see Phase 7 — Migration .


Emergency Procedures

Immediate Shutdown

# VM: macOS VMs — stop the VM from host (fastest, kills everything)
lume stop openclaw-vm       # Lume
prlctl stop openclaw-vm     # Parallels

# VM: Linux VMs — stop the VM from host
multipass stop openclaw-vm          # Multipass
virsh shutdown openclaw-vm          # KVM/libvirt

# VM isolation — inside the VM (graceful)
OPENCLAW_UID=$(id -u openclaw)
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway   # macOS VM (LaunchAgent)
sudo launchctl bootout system/ai.openclaw.gateway               # macOS VM (LaunchDaemon, hardened)
sudo systemctl stop openclaw-gateway                             # Linux VM

# Docker isolation (LaunchAgent on host — default)
OPENCLAW_UID=$(id -u openclaw)
sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway

# Docker isolation (LaunchDaemon on host — hardened)
sudo launchctl bootout system/ai.openclaw.gateway

# Linux (Docker isolation on Linux host)
sudo systemctl stop openclaw-gateway

Remote Shutdown (via Tailscale SSH)

# VM: macOS VMs — stop VM from host
ssh user@<tailscale-ip> 'lume stop openclaw-vm'       # Lume
ssh user@<tailscale-ip> 'prlctl stop openclaw-vm'     # Parallels

# VM: Linux VMs — stop VM from host
ssh user@<tailscale-ip> 'multipass stop openclaw-vm'       # Multipass
ssh user@<tailscale-ip> 'virsh shutdown openclaw-vm'       # KVM/libvirt

# Docker isolation (macOS host, LaunchAgent — default)
ssh user@<tailscale-ip> 'OPENCLAW_UID=$(id -u openclaw); sudo launchctl bootout gui/$OPENCLAW_UID/ai.openclaw.gateway'

# Docker isolation (macOS host, LaunchDaemon — hardened)
ssh user@<tailscale-ip> 'sudo launchctl bootout system/ai.openclaw.gateway'

# Docker isolation (Linux host)
ssh user@<tailscale-ip> 'sudo systemctl stop openclaw-gateway'

Session Reset

sudo -u openclaw openclaw sessions reset

Incident Response

If you suspect compromise, follow this sequence:

  1. Contain — stop the gateway immediately (see Immediate Shutdown above)
  2. Rotate credentials:
    • Gateway token — rotate in the LaunchAgent plist (macOS, or LaunchDaemon for hardened setup) or /etc/openclaw/secrets.env (Linux)
    • API keys — rotate Anthropic, Perplexity/Brave keys in the same plist or env file; also update auth-profiles.json if used
    • Channel credentials — re-pair WhatsApp (scan new QR) or re-link Signal
  3. Audit — review logs and session transcripts for unauthorized actions:
    # Recent gateway logs (macOS: /Users/openclaw, Linux: /home/openclaw)
    tail -100 ~openclaw/.openclaw/logs/gateway.log
    
    # Session transcripts (look for unexpected tool calls)
    ls -lt ~openclaw/.openclaw/agents/*/sessions/*.jsonl | head -20
  4. Restart the gateway with rotated credentials
  5. Report vulnerabilities to security@openclaw.ai

See the official security docs for additional context on known attack patterns.


Next Steps

Your OpenClaw deployment is production-ready.

Phase 7 — Migration — moving a deployment to a new machine → Reference — config cheat sheet, tool list, gotchas, emergency procedures

Or review:

Last updated on