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
- Clone repo, customize
fly.toml - Create app + volume, set secrets
- Deploy with
fly deploy - 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:
| Setting | Why |
|---|---|
--bind lan | Binds to 0.0.0.0 so Fly's proxy can reach the gateway |
--allow-unconfigured | Starts without a config file (you create one after) |
internal_port = 3000 | Must 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:
- ngrok tunnel: run ngrok inside the container or as a sidecar
- Tailscale Funnel: expose specific paths through Tailscale
- 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
| Aspect | Public | Private |
|---|---|---|
| Internet scanners | Discoverable | Hidden |
| Direct attacks | Possible | Blocked |
| Control UI access | Browser | Proxy/VPN |
| Webhook delivery | Direct | Via tunnel |
Notes
- Fly.io runs on x86 architecture; the Dockerfile works on both x86 and ARM.
- For WhatsApp/Telegram onboarding,
fly ssh consoleis 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
- Get messaging channels set up: Channels
- Configure the Gateway: Gateway configuration
- Keep OpenClaw current: Updating