A practical, step-by-step guide to running your own OpenClaw AI assistant on an AWS EC2 instance, secured with Tailscale. Based on a real deployment — every command has been tested and every gotcha is documented.
What you'll get: A private, always-on AI assistant accessible from any device on your Tailscale network, with web search capabilities via Brave Search.
Time required: ~60 minutes Monthly cost: ~$33 (t3.medium + 30GB storage)
Your Mac (Pulumi CLI) → AWS APIs → Creates EC2 instance
└─ Cloud-init auto-installs:
├─ Node.js 22 (via NVM)
├─ OpenClaw
└─ Tailscale
Your browser → Tailscale VPN → EC2:18789 (OpenClaw Gateway)OpenClaw runs entirely on the EC2 instance. Your Mac only needs Pulumi to provision the infrastructure and Tailscale to access the Web UI. No ports are exposed to the public internet.
Gather credentials and install local tools before starting the deployment.
Save your key (starts with sk-ant-api03-...).
Tailscale creates a private mesh VPN so you can access OpenClaw securely without exposing any ports to the internet.
taila1b2c3.ts.nettskey-auth-...)These run on your Mac to provision the EC2 infrastructure:
# Node.js 22+ (required for Pulumi)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc # or ~/.bashrc
nvm install 22
# Pulumi CLI
curl -fsSL https://get.pulumi.com | sh
# AWS CLI
brew install awscliVerify everything:
node --version # Should show v22.x.x
pulumi version # Should show v3.x.x
aws --version # Should show aws-cli/2.x.x| Item | Status |
|---|---|
| Anthropic API Key (with billing enabled) | ☐ |
| Brave Search API Key | ☐ |
| Tailscale Auth Key (reusable) | ☐ |
| Tailnet DNS Name | ☐ |
| Node.js 22+ installed locally | ☐ |
| Pulumi CLI installed locally | ☐ |
| AWS CLI installed locally | ☐ |
Create an IAM user that Pulumi will use to provision resources.
pulumi-deployerAmazonEC2FullAccessAmazonVPCFullAccessaws configureEnter when prompted:
AWS Access Key ID: AKIA...your-key...
AWS Secret Access Key: ...your-secret...
Default region name: us-east-1
Default output format: jsonVerify it works:
aws sts get-caller-identityYou should see your account ID and the pulumi-deployer user ARN.
Troubleshooting: If you get
InvalidClientTokenId, the most common cause is a copy/paste error with whitespace. Runaws configureagain and carefully re-enter both keys. You can verify what's stored withaws configure list.
mkdir ~/openclaw-aws
cd ~/openclaw-aws
pulumi new typescript --name openclaw-aws --description "OpenClaw deployment on AWS EC2"When prompted:
devnpmTroubleshooting: If the directory already exists from a previous attempt, either use
--forceor delete it first withrm -rf ~/openclaw-awsand start over.
npm install @pulumi/aws @pulumi/tlsindex.tsOpen index.ts and replace the entire contents with the following. This creates a VPC, subnet, security group, and EC2 instance with a cloud-init script that automatically installs OpenClaw:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as tls from "@pulumi/tls";
import * as crypto from "crypto";
// ── Config ──────────────────────────────────────────────────────────
const cfg = new pulumi.Config();
const anthropicApiKey = cfg.requireSecret("anthropicApiKey");
const tailscaleAuthKey = cfg.requireSecret("tailscaleAuthKey");
const tailnetDnsName = cfg.require("tailnetDnsName");
// Generate a random gateway token
const gatewayToken = crypto.randomBytes(24).toString("hex");
// ── VPC ─────────────────────────────────────────────────────────────
const vpc = new aws.ec2.Vpc("openclaw-vpc", {
cidrBlock: "10.0.0.0/16",
enableDnsHostnames: true,
enableDnsSupport: true,
tags: { Name: "openclaw-vpc" },
});
const subnet = new aws.ec2.Subnet("openclaw-subnet", {
vpcId: vpc.id,
cidrBlock: "10.0.1.0/24",
mapPublicIpOnLaunch: true,
availabilityZone: "us-east-1a",
tags: { Name: "openclaw-subnet" },
});
const igw = new aws.ec2.InternetGateway("openclaw-igw", {
vpcId: vpc.id,
tags: { Name: "openclaw-igw" },
});
const rt = new aws.ec2.RouteTable("openclaw-rt", {
vpcId: vpc.id,
routes: [{ cidrBlock: "0.0.0.0/0", gatewayId: igw.id }],
tags: { Name: "openclaw-rt" },
});
new aws.ec2.RouteTableAssociation("openclaw-rta", {
subnetId: subnet.id,
routeTableId: rt.id,
});
// ── Security Group ──────────────────────────────────────────────────
const sg = new aws.ec2.SecurityGroup("openclaw-sg", {
vpcId: vpc.id,
description: "OpenClaw - SSH only (Tailscale handles app access)",
ingress: [
{ protocol: "tcp", fromPort: 22, toPort: 22,
cidrBlocks: ["0.0.0.0/0"], description: "SSH fallback" },
],
egress: [
{ protocol: "-1", fromPort: 0, toPort: 0,
cidrBlocks: ["0.0.0.0/0"], description: "All outbound" },
],
tags: { Name: "openclaw-sg" },
});
// ── SSH Key ─────────────────────────────────────────────────────────
const sshKey = new tls.PrivateKey("openclaw-key", { algorithm: "RSA", rsaBits: 4096 });
const keyPair = new aws.ec2.KeyPair("openclaw-keypair", {
publicKey: sshKey.publicKeyOpenssh,
});
// ── AMI ─────────────────────────────────────────────────────────────
const ami = aws.ec2.getAmiOutput({
mostRecent: true,
owners: ["099720109477"], // Canonical
filters: [
{ name: "name", values: ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] },
{ name: "state", values: ["available"] },
],
});
// ── Cloud-Init Script ───────────────────────────────────────────────
const userData = pulumi.all([anthropicApiKey, tailscaleAuthKey]).apply(
([apiKey, tsKey]) => `#!/bin/bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# System updates
apt-get update -y
apt-get upgrade -y
# Install NVM and Node.js 22 for ubuntu user
sudo -u ubuntu bash -c '
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
export NVM_DIR="/home/ubuntu/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm install 22
nvm use 22
npm install -g openclaw@latest
'
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --authkey="${tsKey}" --ssh
# Run OpenClaw onboarding as ubuntu user
sudo -u ubuntu bash -c '
export NVM_DIR="/home/ubuntu/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
# Run onboarding (may show a warning about gateway connection - this is expected)
openclaw onboard --install-daemon --no-welcome || echo "WARNING: OpenClaw onboarding failed"
'
# Configure OpenClaw
sudo -u ubuntu bash -c '
cat > ~/.openclaw/openclaw.json << JSONEOF
{
"messages": {
"ackReactionScope": "group-mentions"
},
"agents": {
"defaults": {
"maxConcurrent": 4,
"subagents": { "maxConcurrent": 8 },
"contextPruning": { "mode": "cache-ttl", "ttl": "1h" },
"heartbeat": { "every": "30m" },
"compaction": { "mode": "safeguard" },
"workspace": "/home/ubuntu/.openclaw/workspace"
}
},
"gateway": {
"mode": "local",
"auth": {
"mode": "token",
"token": "${gatewayToken}"
},
"port": 18789,
"bind": "loopback",
"tailscale": { "mode": "off", "resetOnExit": false },
"trustedProxies": ["127.0.0.1"],
"controlUi": {
"enabled": true,
"allowInsecureAuth": true
}
},
"auth": {
"profiles": {
"anthropic:default": {
"provider": "anthropic",
"mode": "api_key"
}
}
}
}
JSONEOF
# Set up the Anthropic API key in the agent auth profile
mkdir -p ~/.openclaw/agents/main/agent
cat > ~/.openclaw/agents/main/agent/auth-profiles.json << AUTHEOF
{
"version": 1,
"profiles": {
"anthropic:default": {
"type": "api_key",
"provider": "anthropic",
"key": "${apiKey}"
}
},
"lastGood": {
"anthropic": "anthropic:default"
}
}
AUTHEOF
'
# Install and start the daemon
sudo -u ubuntu bash -c '
export NVM_DIR="/home/ubuntu/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
openclaw daemon install || true
openclaw gateway restart || true
'
# Enable lingering so the user service starts on boot
loginctl enable-linger ubuntu
# Configure Tailscale HTTPS proxy
tailscale serve --bg 18789
echo "OpenClaw setup complete!"
`);
// ── EC2 Instance ────────────────────────────────────────────────────
const instance = new aws.ec2.Instance("openclaw-instance", {
ami: ami.id,
instanceType: "t3.medium",
subnetId: subnet.id,
vpcSecurityGroupIds: [sg.id],
keyName: keyPair.keyName,
userData: userData,
rootBlockDevice: {
volumeSize: 30,
volumeType: "gp3",
},
tags: { Name: "openclaw" },
});
// ── Outputs ─────────────────────────────────────────────────────────
const tailscaleHostname = instance.privateIp.apply(
ip => `ip-${ip.replace(/\./g, "-")}.${tailnetDnsName}`
);
export const publicIp = instance.publicIp;
export const publicDns = instance.publicDns;
export const sshCommand = pulumi.interpolate`ssh -i key.pem ubuntu@${instance.publicIp}`;
export const privateKey = sshKey.privateKeyPem;
export const gatewayTokenOutput = gatewayToken;
export const tailscaleUrl = pulumi.interpolate`https://${tailscaleHostname}/`;
export const tailscaleUrlWithToken = pulumi.interpolate`https://${tailscaleHostname}/?token=${gatewayToken}`;Run each command one at a time, replacing the placeholder values with your actual credentials:
cd ~/openclaw-aws
# Your Anthropic API key (encrypted in state)
pulumi config set anthropicApiKey YOUR_ANTHROPIC_API_KEY --secret
# Your Tailscale auth key (encrypted in state)
pulumi config set tailscaleAuthKey YOUR_TAILSCALE_AUTH_KEY --secret
# Your Tailnet DNS name (plaintext)
pulumi config set tailnetDnsName YOUR_TAILNET_NAME.ts.net
# AWS region
pulumi config set aws:region us-east-1Verify the config file:
cat Pulumi.dev.yamlYou should see your Tailnet name in plain text and the other values encrypted.
pulumi previewYou should see ~15 resources to be created. Review to make sure it looks clean.
pulumi upType yes when prompted. Deployment takes about 2 minutes. When complete, you'll see outputs including:
publicIp — the EC2 public IPsshCommand — how to SSH intailscaleUrlWithToken — your Web UI URL (save this!)gatewayTokenOutput — your gateway auth tokenpulumi stack output privateKey --show-secrets > key.pem
chmod 600 key.pemThe EC2 instance runs a cloud-init script that installs everything automatically. Monitor progress:
ssh -i key.pem ubuntu@$(pulumi stack output publicIp)Once connected:
tail -f /var/log/cloud-init-output.logWait until you see: OpenClaw setup complete!
Note: You may see a warning like
WARNING: OpenClaw onboarding failedwith a message about "gateway closed (1006 abnormal closure)". This is normal — it happens because the onboarding script tried to connect to the gateway before the daemon was installed. The daemon gets installed right after, so everything works fine.
Press Ctrl+C when done watching.
While SSHed into the instance:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
openclaw --version
openclaw gateway statusYou should see:
2026.1.x or laterNote: You may see a "gateway token mismatch" warning from the CLI. This is cosmetic — it means the CLI doesn't have the token configured for itself, but the Web UI uses the token via URL parameter so it works fine.
On your Mac, make sure Tailscale is connected (check the menu bar icon).
Open the Tailscale URL with token in your browser. You can get it with:
# Run this on your Mac, not on the EC2
cd ~/openclaw-aws
pulumi stack output tailscaleUrlWithTokenIt looks like: https://ip-10-0-1-XXX.yournet.ts.net/?token=abc123...
Send a message in the Web UI:
"Hello! What can you do?"
You should get a response from Claude within a few seconds. The assistant will introduce itself and ask you to personalize it.
Troubleshooting — Empty responses: If the assistant appears to respond but messages are blank:
- SSH into the EC2 and check logs:
openclaw logs --limit 20- If runs complete in ~300ms (too fast for real API calls), the API key likely has no billing credits
- Go to https://console.anthropic.com/settings/plans and add credits
- Retry — no restart needed
Give your assistant the ability to search the web.
ssh -i ~/openclaw-aws/key.pem ubuntu@$(cd ~/openclaw-aws && pulumi stack output publicIp)Load NVM:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"nano ~/.openclaw/openclaw.jsonFind the "auth" section and add a "tools" block before it. The relevant portion should look like:
},
"tools": {
"web": {
"search": {
"enabled": true,
"provider": "brave",
"apiKey": "YOUR_BRAVE_API_KEY",
"maxResults": 5
},
"fetch": {
"enabled": true
}
}
},
"auth": {Replace YOUR_BRAVE_API_KEY with your actual Brave Search API key.
Save with Ctrl+O, Enter, Ctrl+X.
openclaw gateway restartGo to the Web UI and send:
"Search the web for the latest AI news today"
The assistant should perform a web search and return a summary with sources. The response will take 10-25 seconds as it searches and processes multiple results.
Before removing the SSH fallback, confirm you can SSH via Tailscale:
# Find your EC2's Tailscale hostname
tailscale status # Run on your Mac
# SSH via Tailscale (no key needed — Tailscale handles auth)
ssh ubuntu@ip-10-0-1-XXX # Use the Tailscale hostnameOnce Tailscale SSH is confirmed working, you can remove the public SSH security group rule so the instance has zero public ports. Edit the index.ts security group to have an empty ingress array, then run pulumi up.
Since credentials were handled during setup, rotate them all:
~/.openclaw/agents/main/agent/auth-profiles.json on the EC2~/.openclaw/openclaw.json on the EC2After updating any keys on the EC2, restart the gateway:
openclaw gateway restart# Always load NVM first in a new SSH session
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# Gateway management
openclaw gateway status # Check if running
openclaw gateway restart # Restart after config changes
openclaw gateway stop # Stop the gateway
# Logs
openclaw logs --limit 30 # Recent logs
tail -f /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log # Live log stream
# Diagnostics
openclaw --version # Check version
openclaw doctor # Run diagnostics
# Configuration files
cat ~/.openclaw/openclaw.json # Main config
cat ~/.openclaw/agents/main/agent/auth-profiles.json # API keyscd ~/openclaw-aws
# Infrastructure management
pulumi stack output tailscaleUrlWithToken # Get your Web UI URL
pulumi stack output publicIp # Get the EC2 public IP
pulumi up # Apply changes
pulumi destroy # Tear everything down
# SSH access
ssh -i key.pem ubuntu@$(pulumi stack output publicIp)| File | Purpose |
|---|---|
~/.openclaw/openclaw.json | Main configuration (gateway, tools, agents) |
~/.openclaw/agents/main/agent/auth-profiles.json | API keys for the main agent |
~/.openclaw/workspace/ | Assistant's working directory |
~/.openclaw/agents/main/sessions/ | Conversation history |
/tmp/openclaw/ | Log files |
~/.config/systemd/user/openclaw-gateway.service | Systemd service definition |
To destroy all AWS resources and stop billing:
cd ~/openclaw-aws
pulumi destroyType yes to confirm. This removes the EC2 instance, VPC, and all associated resources.
NVM isn't loaded in your current shell. Run:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"The API key doesn't have billing credits. Go to https://console.anthropic.com/settings/plans, add credits, and retry. No restart needed.
This appears when the CLI can't authenticate to the gateway. It's cosmetic — the Web UI works because it passes the token as a URL parameter. The gateway itself is running fine.
This is expected. The onboarding script runs before the daemon is fully installed and tries to connect to the gateway. The daemon gets installed immediately after, and everything works. Check for OpenClaw setup complete! at the end of the log.
Brave Search API may occasionally time out. Retry the message. If it keeps happening, check your Brave API key quota at https://brave.com/search/api/ (free tier is 2,000 queries/month).
tailscale status| Instance | RAM | Monthly Cost | Notes |
|---|---|---|---|
| t3.micro | 1 GB | ~$8 | ❌ Installation fails — not enough memory |
| t3.small | 2 GB | ~$16 | ⚠️ Marginal — may work but tight |
| t3.medium | 4 GB | ~$33 | ✅ Recommended — works reliably |
| t3.large | 8 GB | ~$67 | ✅ Good for heavy browser automation |
Once your assistant is running, you can:
openclaw skills search on the EC2For more, see the official docs at https://docs.openclaw.ai/