Secure and hardened OpenClaw setup on a VPS
I have been experimenting with OpenClaw recently, and while the initial setup is fairly straightforward, the secure setup is where most of the interesting decisions actually are.
The difference between “it works” and “it is safe enough to leave running on a VPS” is not small. If you are running an agent that can read files, write code, install packages, and expose a gateway for device pairing, you really do not want to treat the box like a throwaway toy project.
So in this post I am going to walk through the setup I ended up with for OpenClaw on a VPS, but with an emphasis on hardening the machine first, and only then installing the app. The main goal is simple:
- lock down SSH
- keep the OpenClaw gateway on loopback only
- avoid exposing unnecessary ports
- run the service in a way that survives rebuilds and reboots
This is not the only way to do it, but it is a pragmatic setup that I would be comfortable running.
First: harden the server before installing anything
Start by connecting to your VPS as root.
ssh root@YOUR_VPS_IP
If this is the first time you connect, type yes at the SSH fingerprint prompt.
Once you are in, update the machine.
apt-get update && apt-get upgrade -y
Then install the basic packages we need along the way.
apt-get install -y git curl ca-certificates
Now install Docker:
curl -fsSL https://get.docker.com | sh
And after that, install the basic hardening tools:
apt install ufw fail2ban unattended-upgrades -y
Enable automatic security updates as well:
dpkg-reconfigure -plow unattended-upgrades
There is nothing OpenClaw-specific here yet, and that is the point. Before giving an AI agent a home on your VPS, make sure the VPS itself is not casually exposed.
Configure the firewall before OpenClaw exists
This is one of the most important parts of the whole setup.
The gateway should not be reachable from the public internet. It should bind to loopback and only be reachable locally on the server. So before installing OpenClaw, configure the firewall to allow only SSH.
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw enable
ufw status verbose
What you should not do is this:
ufw allow 18789/tcp
That would expose the OpenClaw gateway directly to the internet, which defeats the point of keeping it local in the first place.
Create a separate user and stop using root
Next, create a dedicated user for running and managing OpenClaw.
adduser openclaw
Set a password, skip the optional name and phone fields, and confirm with Y.
Then give that user sudo and Docker access:
usermod -aG sudo openclaw
usermod -aG docker openclaw
Copy over your SSH key so you can log in as the new user:
mkdir -p /home/openclaw/.ssh
cp /root/.ssh/authorized_keys /home/openclaw/.ssh/
chown -R openclaw:openclaw /home/openclaw/.ssh
chmod 700 /home/openclaw/.ssh
chmod 600 /home/openclaw/.ssh/authorized_keys
Now test it in a new terminal tab, while keeping your root session open:
ssh openclaw@YOUR_VPS_IP
If you see a shell prompt for openclaw, you are good.
Only after that should you lock down root SSH access:
echo "PermitRootLogin no" >> /etc/ssh/sshd_config
echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
systemctl restart ssh
And verify:
ssh root@YOUR_VPS_IP
This should now fail with Permission denied.
From this point on, use the openclaw user only.
Clone the repo and create persistent directories
Log in as the openclaw user and update packages once more:
sudo apt-get update && sudo apt-get upgrade -y
Then clone the repository:
git clone https://github.com/openclaw/openclaw.git
cd openclaw
Create the directories that should survive container rebuilds:
sudo mkdir -p /home/openclaw/.openclaw/workspace
sudo chown -R openclaw:openclaw /home/openclaw/.openclaw
I prefer keeping this data outside the container so configuration, auth state, and workspace files do not disappear when rebuilding images or replacing containers.
Protect the environment file
Once you create your .env, lock it down immediately:
chmod 600 .env
That is just a small step, but it is one of those small steps that should always be there.
Why the default Docker Compose file is not enough
This is where the setup starts getting more opinionated.
The default docker-compose.yml is not what I would call a hardened setup. There are a few things we want to change:
- We want the gateway to build from the local
Dockerfile, because we are going to tweak that file. - We want host networking so device pairing works reliably.
- We want the gateway bound to loopback, not something reachable from outside.
- We want restart policies so the service comes back after reboot or crashes.
- We do not want leftover Anthropic session variables hanging around if we are not using them.
If you want to edit comfortably, this is one of those cases where VS Code Remote SSH is much nicer than editing everything with nano.
The file we are changing is:
nano /home/openclaw/openclaw/docker-compose.yml
And this is the version I ended up using:
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-openclaw:local}
build: .
environment:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
NODE_ENV: production
OPENCLAW_GATEWAY_BIND: ${OPENCLAW_GATEWAY_BIND:-loopback}
OPENCLAW_GATEWAY_PORT: ${OPENCLAW_GATEWAY_PORT:-18789}
OPENCLAW_SECRET: ${OPENCLAW_SECRET}
XDG_CONFIG_HOME: ${XDG_CONFIG_HOME}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
network_mode: host
init: true
restart: unless-stopped
command:
["node", "dist/index.js", "gateway",
"--bind", "${OPENCLAW_GATEWAY_BIND:-loopback}",
"--port", "18789"]
openclaw-cli:
image: ${OPENCLAW_IMAGE:-openclaw:local}
environment:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
BROWSER: echo
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
stdin_open: true
tty: true
init: true
entrypoint: ["node", "dist/index.js"]
The most important line here is probably this one:
OPENCLAW_GATEWAY_BIND: ${OPENCLAW_GATEWAY_BIND:-loopback}
loopback means the gateway listens on 127.0.0.1 only. That is exactly what we want.
Another important change is removing the ports: section entirely. If you expose the gateway with normal Docker port publishing and then open it in the firewall, you have made the box much more reachable than necessary.
Modify the Dockerfile
There is one small but practical modification I had to make in the Dockerfile.
Open it:
nano Dockerfile
Near the bottom you will find:
USER node
Comment that out:
# USER node
Normally I prefer dropping privileges inside containers, but in this case the agent needs to install tools, manage packages, and write files in ways that quickly run into permissions friction. Leaving that line in place caused more trouble than value for my use case.
Build and run onboarding
Now build the images:
docker compose build
Then run the onboarding wizard:
docker compose run --rm openclaw-cli onboard
I went with QuickStart, then OpenAI (Codex OAuth + API key), followed by OpenAI Codex (ChatGPT OAuth), but you can pick whichever provider works for you.
The OAuth flow is slightly awkward the first time, but it is simple enough:
- The wizard prints a URL.
- Open it in your laptop browser.
- Log in and approve.
- The browser redirects to something like
http://localhost:xxxx/auth/callback?code=... - The page may say it cannot connect. That is normal.
- Copy the entire redirected URL and paste it back into the terminal.
If you want Telegram, configure it during onboarding. I skipped the optional skills, hooks, and extra API key prompts for the initial setup.
Fix the bind mode if the wizard changes it
After onboarding, verify that the gateway bind value is still correct:
grep '"bind"' /home/openclaw/.openclaw/openclaw.json
It should say:
"bind": "loopback"
If it says lan, change it. This is not a cosmetic setting. It controls whether the gateway stays local or starts listening more broadly than intended.
Fix the gateway token mismatch
This was the part that felt most likely to waste time later if left unnoticed.
The onboarding wizard can write its own gateway token into openclaw.json, and that token may not match the one in your .env. If the tokens differ, you will get weird failures later: CLI commands fail, subagents do not spawn correctly, cron jobs misbehave, and the setup feels broken in ways that are harder to reason about than they should be.
Check the token written by the wizard:
python3 -c "import json; c=json.load(open('/home/openclaw/.openclaw/openclaw.json')); print(c['gateway']['auth']['token'])"
Then compare it with the token in .env:
grep OPENCLAW_GATEWAY_TOKEN /home/openclaw/openclaw/.env
If they are different, edit the JSON file and make them match:
nano /home/openclaw/.openclaw/openclaw.json
The final openclaw.json gateway shape
The gateway section in openclaw.json should look like this:
"gateway": {
"port": 18789,
"mode": "local",
"bind": "loopback",
"auth": {
"mode": "token",
"token": "YOUR_TOKEN_FROM_ENV_FILE",
"rateLimit": {
"maxAttempts": 10,
"windowMs": 60000,
"lockoutMs": 300000
}
},
"controlUi": {
"enabled": true,
"allowInsecureAuth": false
}
}
There are three fields here that matter a lot:
bind: "loopback"keeps the gateway local to127.0.0.1allowInsecureAuth: falseensures you do not weaken device pairingrateLimitgives you a basic brute-force safety net
This is the part where the setup stops being “just a local tool running in Docker” and starts becoming a service you can operate with a bit more confidence.
Final thoughts
OpenClaw is not particularly hard to get running. The hard part is resisting the temptation to stop the moment you see the happy path working.
A secure setup here is mostly about discipline:
- do the firewall first
- move off root
- keep the gateway on loopback
- do not open the port publicly
- keep persistent data outside the container
- make sure the tokens are actually in sync
If you skip those parts, the setup is technically “done”, but not in a way I would feel good about leaving on a VPS.
My general rule is simple: whenever a tool can execute commands, install packages, and read or write files, the surrounding environment matters as much as the tool itself.
That is exactly the kind of software that deserves a hardened setup from day one.