Deploy OpenClaw on Fly.io with Persistent Storage and HTTPS

Step-by-step guide to deploying OpenClaw Gateway on Fly.io, including persistent storage, automatic HTTPS, and channel access. Ideal for developers using Discord or Telegram.

Read this when

  • Deploying OpenClaw on Fly.io
  • Setting up Fly volumes, secrets, and first-run config

Goal: Deploy OpenClaw Gateway on a Fly.io instance, complete with persistent storage, automatic HTTPS, and Discord/channel access.

What you need

  • flyctl CLI installed
  • Fly.io account (free tier works)
  • Model auth: API key for your chosen model provider
  • Channel credentials: Discord bot token, Telegram token, etc.

Beginner quick path

  1. Clone repo, customize fly.toml
  2. Create app + volume, set secrets
  3. Deploy with fly deploy
  4. SSH in to create config, or use the Control UI

Create the Fly app

git clone https://github.com/openclaw/openclaw.git
cd openclaw

# pick your own name
fly apps create my-openclaw

# 1GB is usually enough
fly volumes create openclaw_data --size 1 --region iad

Pick a region near you. Popular choices: lhr (London), iad (Virginia), sjc (San Jose).

Configure fly.toml

Modify fly.toml so it reflects your app name and needs. The version of fly.toml tracked in the repo is the public template shown below; deploy/fly.private.toml is the hardened variant with no public IP (see Private deployment).

app = "my-openclaw"  # your app name
primary_region = "iad"

[build]
  dockerfile = "Dockerfile"

[env]
  NODE_ENV = "production"
  OPENCLAW_PREFER_PNPM = "1"
  OPENCLAW_STATE_DIR = "/data"
  NODE_OPTIONS = "--max-old-space-size=1536"

[processes]
  app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 1
  processes = ["app"]

[[http_service.checks]]
  grace_period = "2m"
  interval = "15s"
  method = "GET"
  timeout = "5s"
  path = "/startupz"

[[vm]]
  size = "shared-cpu-2x"
  memory = "2048mb"

[mounts]
  source = "openclaw_data"
  destination = "/data"

OpenClaw's Docker image uses tini as its entrypoint, with node openclaw.mjs gateway as the default. Fly's [processes] takes the place of Docker's CMD (in this case, it runs node dist/index.js gateway ... directly, the same compiled entrypoint) and leaves ENTRYPOINT untouched, so the process still executes under tini.

Key settings:

SettingWhy
--bind lanBinds to 0.0.0.0 so Fly's proxy can reach the gateway
--allow-unconfiguredStarts without a config file (you create one after)
internal_port = 3000Must match --port 3000 (or OPENCLAW_GATEWAY_PORT) for Fly health checks
path = "/startupz"Admits traffic after Gateway startup finishes, independent of channel health
memory = "2048mb"512MB is too small; 2GB recommended
OPENCLAW_STATE_DIR = "/data"Persists state on the volume

Set secrets

# required: gateway auth token for non-loopback binding
fly secrets set OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)

# model provider API keys
fly secrets set ANTHROPIC_API_KEY=example-anthropic-key-not-real

# optional: other providers
fly secrets set OPENAI_API_KEY=example-openai-key-not-real
fly secrets set GOOGLE_API_KEY=...

# channel tokens
fly secrets set DISCORD_BOT_TOKEN=example-discord-bot-token

When binding to a non-loopback address (--bind lan), a valid gateway auth path is mandatory. The example relies on OPENCLAW_GATEWAY_TOKEN, though gateway.auth.password or a properly set up non-loopback trusted-proxy deployment also meets the requirement. Refer to Secrets management for the SecretRef contract.

Handle these tokens like passwords. Keep API keys and tokens in env vars or fly secrets instead of the config file, so secrets never land in openclaw.json.

Deploy

fly deploy

The initial deploy builds the Docker image. Once it's live, check the following:

fly status
fly logs

Gateway startup logs gateway ready as soon as the HTTP/WebSocket listener is ready. Fly probes /startupz on internal_port = 3000 and opens traffic once startup work completes. The image's Docker HEALTHCHECK figures out the active Gateway lock port, so its /healthz liveness check also honors this deployment's --port 3000 override.

Create config file

SSH into the machine to write a proper config:

fly ssh console
mkdir -p /data
cat > /data/openclaw.json << 'EOF'
{
  "agents": {
    "defaults": {
      "model": {
        "primary": "anthropic/claude-opus-4-6",
        "fallbacks": ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"]
      },
      "maxConcurrent": 4
    },
    "entries": {
      "main": {
        "default": true
      }
    }
  },
  "auth": {
    "profiles": {
      "anthropic:default": { "mode": "token", "provider": "anthropic" },
      "openai:default": { "mode": "token", "provider": "openai" }
    }
  },
  "bindings": [
    {
      "agentId": "main",
      "match": { "channel": "discord" }
    }
  ],
  "channels": {
    "discord": {
      "enabled": true,
      "groupPolicy": "allowlist",
      "guilds": {
        "YOUR_GUILD_ID": {
          "channels": { "general": { "enabled": true } },
          "requireMention": false
        }
      }
    }
  },
  "gateway": {
    "mode": "local",
    "bind": "auto",
    "controlUi": {
      "allowedOrigins": [
        "https://my-openclaw.fly.dev",
        "http://localhost:3000",
        "http://127.0.0.1:3000"
      ]
    }
  },
  "meta": {}
}
EOF

With OPENCLAW_STATE_DIR=/data, the config lives at /data/openclaw.json.

Swap https://my-openclaw.fly.dev for your actual Fly app origin. Gateway startup seeds local Control UI origins from the runtime --bind and --port values, allowing first boot to proceed before any config exists, but browser access through Fly still requires the exact HTTPS origin listed in gateway.controlUi.allowedOrigins.

The channels.discord block above turns on Discord. Its token can come from either:

  • Environment variable DISCORD_BOT_TOKEN (best for secrets); the default account reads it automatically
  • Config file channels.discord.token

Restart to apply:

exit
fly machine restart <machine-id>

Access the Gateway

Control UI

fly open

Or visit https://my-openclaw.fly.dev/.

Use the shared secret to authenticate: the gateway token from OPENCLAW_GATEWAY_TOKEN, or your password if password auth is enabled.

Logs

fly logs              # live logs
fly logs --no-tail    # recent logs

SSH console

fly ssh console

Troubleshooting

"App is not listening on expected address"

The gateway is listening on 127.0.0.1 rather than 0.0.0.0.

Fix: append --bind lan to your process command in fly.toml.

Health checks failing / connection refused

Fly cannot reach the gateway on the configured port, or /startupz still indicates startup activity.

Fix: verify internal_port aligns with the gateway port (--port 3000 or OPENCLAW_GATEWAY_PORT=3000), then check fly logs for the incomplete startup step.

OOM / memory issues

Container keeps restarting or being killed. Symptoms: SIGABRT, v8::internal::Runtime_AllocateInYoungGeneration, or restarts with no output.

Fix: raise memory in fly.toml:

[[vm]]
  memory = "2048mb"

Or adjust an existing machine:

fly machine update <machine-id> --vm-memory 2048 -y

512MB is insufficient. 1GB might work but can OOM under load or with verbose logging. 2GB is recommended.

Gateway lock issues

Gateway fails to start with "already running" errors after a container restart.

With OPENCLAW_STATE_DIR=/data, the lock tree resides under /data/tmp/openclaw-<uid> and stays with the volume. OpenClaw usually reclaims stale owners on its own. If startup still reports an owner, run fly status and fly logs first to confirm no other machine or Gateway process is using the volume. Do not remove the lock tree while an owner might still be active; see Gateway lock for the ownership and stale-recovery contract.

Config not being read

--allow-unconfigured only skips the startup guard. It does not create or fix /data/openclaw.json, so confirm your actual config exists and includes "gateway": { "mode": "local" } for a standard local gateway start.

Check the config exists:

fly ssh console --command "cat /data/openclaw.json"

Writing config via SSH

fly ssh console -C lacks shell redirection support. To write a config file:

# echo + tee (pipe from local to remote)
echo '{"your":"config"}' | fly ssh console -C "tee /data/openclaw.json"

# or sftp
fly sftp shell
> put /local/path/config.json /data/openclaw.json

fly sftp can fail if the file already exists; remove it first:

fly ssh console --command "rm /data/openclaw.json"

State not persisting

If auth profiles, channel/provider state, or sessions vanish after a restart, the state dir is targeting the container filesystem instead of the volume.

Fix: confirm OPENCLAW_STATE_DIR=/data is set in fly.toml and redeploy.

Updating

git pull
fly deploy
fly status
fly logs

git pull + fly deploy is the supervised route here: it rebuilds the image from the Dockerfile, so the CLI/gateway version, the base OS image, and any Dockerfile changes all update together. openclaw update inside the running container is not the same operation, since the image ships as a Docker-built dist/ tree with no .git checkout and no npm-managed global install for it to detect; see Updating for that flow on VM-style installs.

Updating the machine command

To change the startup command without a full redeploy:

fly machines list
fly machine update <machine-id> --command "node dist/index.js gateway --port 3000 --bind lan" -y

# or with a memory increase
fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js gateway --port 3000 --bind lan" -y

A subsequent fly deploy restores the machine command to whatever fly.toml contains; after a redeploy, any manual changes need to be reapplied.

Private deployment (hardened)

Public IPs are assigned by default on Fly, which makes your gateway reachable at https://your-app.fly.dev and visible to internet scanners such as Shodan and Censys.

For a hardened setup with no public IP, go with deploy/fly.private.toml: it leaves out [http_service], so no public ingress gets allocated.

When to use private deployment

  • Only outbound calls and messages are needed, no inbound webhooks
  • Webhook callbacks are handled through ngrok or Tailscale tunnels
  • Gateway access happens via SSH, proxy, or WireGuard rather than a browser
  • The deployment must stay hidden from internet scanners

Setup

fly deploy -c deploy/fly.private.toml

Alternatively, convert an existing deployment:

# list current IPs
fly ips list -a my-openclaw

# release public IPs
fly ips release <public-ipv4> -a my-openclaw
fly ips release <public-ipv6> -a my-openclaw

# switch to the private config so future deploys do not re-allocate public IPs
fly deploy -c deploy/fly.private.toml

# allocate private-only IPv6
fly ips allocate-v6 --private -a my-openclaw

Following that, fly ips list should display only a private type IP:

VERSION  IP                   TYPE             REGION
v6       fdaa:x:x:x:x::x      private          global

Accessing a private deployment

Option 1: local proxy (simplest)

fly proxy 3000:3000 -a my-openclaw
# open http://localhost:3000 in a browser

Option 2: WireGuard VPN

fly wireguard create
# import to a WireGuard client, then access via internal IPv6
# example: http://[fdaa:x:x:x:x::x]:3000

Option 3: SSH only

fly ssh console -a my-openclaw

Webhooks with private deployment

When webhook callbacks (Twilio, Telnyx, etc.) are needed without public exposure:

  1. ngrok tunnel: run ngrok inside the container or as a sidecar
  2. Tailscale Funnel: expose specific paths through Tailscale
  3. Outbound-only: certain providers (Twilio) handle outbound calls with no webhooks

Here is an example voice-call config with ngrok, under plugins.entries.voice-call.config:

{
  plugins: {
    entries: {
      "voice-call": {
        enabled: true,
        config: {
          provider: "twilio",
          tunnel: { provider: "ngrok" },
          webhookSecurity: {
            allowedHosts: ["example.ngrok.app"],
          },
        },
      },
    },
  },
}

The ngrok tunnel operates inside the container, giving you a public webhook URL while the Fly app itself stays unexposed. Set webhookSecurity.allowedHosts to the tunnel hostname so forwarded host headers get accepted.

Security tradeoffs

AspectPublicPrivate
Internet scannersDiscoverableHidden
Direct attacksPossibleBlocked
Control UI accessBrowserProxy/VPN
Webhook deliveryDirectVia tunnel

Notes

  • Fly.io runs on x86 architecture; the Dockerfile works on both x86 and ARM.
  • For WhatsApp/Telegram onboarding, fly ssh console is the one to use.
  • Persistent data is stored on the volume at /data.
  • Signal needs signal-cli (a Java-based CLI) on the image; go with a custom image and keep memory at 2GB or higher.

Cost

Using the recommended config (shared-cpu-2x, 2GB RAM), budget around $10-15/month depending on usage; the free tier covers a baseline allowance. Current rates are on Fly.io pricing.

Next steps

1,940 words · updated Aug 17, 2026