From Wide Open to Zero Trust: Hardening an OpenClaw Server in One Afternoon
*This is a public, redacted write-up of an actual hardening session. All identifiers, IPs, hostnames, keys, tokens, and user details have been replaced with placeholders like 203.0.113.0/24, example.com, and [REDACTED]. Treat this as a practitioner’s field notes—not a compliance checklist.*
Why this exists
When you spin up a fresh server for automation—especially something like **OpenClaw**, which is designed to orchestrate tools, run jobs, and touch lots of systems—you’re creating a high-leverage target.
A default VPS is usually “fine” for running a blog. It’s *not* fine for running an autonomous agent that:
- can execute commands,
- can store tokens for third-party APIs,
- can run long-lived services,
- and can become a pivot point into the rest of your infrastructure.
The goal for the afternoon was simple:
- Reduce exposed surface area.
- Make brute force and opportunistic scanning mostly pointless.
- Put **everything** behind a **Zero Trust access path** (WireGuard).
- Add continuous visibility: “Did anything change? Did something get vulnerable? Is anything failing?”
This guide walks through the journey in the order we did it, including the parts that were annoying.
---
1) Starting Point: the fresh OpenClaw server reality check
A fresh server build looks deceptively calm. You SSH in, everything works, nothing is on fire.
But security-wise, “fresh” usually means:
- **SSH is open to the whole internet** (`22/tcp`), password auth sometimes enabled, root login sometimes allowed.
- **No monitoring**: if the box runs hot, fills disk, or a service dies, you find out when you feel pain.
- **Services are exposed by convenience**: maybe a web UI on `:8080`, maybe a media server on `:32400`, maybe a download client on `:9091`.
- **No explicit firewall posture**: if a service binds `0.0.0.0`, it’s public.
- **No rate limiting** beyond whatever defaults the kernel happens to provide.
From an attacker’s perspective, this is easy mode: scan, banner-grab, try common creds, look for known CVEs, move on.
From an operator’s perspective, it’s also easy mode—until you realize “easy to deploy” is often “easy to compromise.”
The first thing I did was treat the server like it was already hostile territory: assume it will be scanned constantly, and plan accordingly.
---
2) OS-Level Hardening: lock the front door before reorganizing the house
2.1 SSH: key-only, minimal attack surface
The fastest win on most internet-exposed servers is to make SSH boring:
- **Disable password authentication**.
- **Disable root login**.
- Use **ed25519 keys**.
- Optionally restrict which users can SSH.
Example (/etc/ssh/sshd_config or a drop-in under /etc/ssh/sshd_config.d/):
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
# Optional: only allow a service/admin account
AllowUsers clawadmin
Then:
sudo sshd -t
sudo systemctl reload ssh
**What went wrong (and what I learned):**
- It’s easy to lock yourself out if you do this before confirming key auth works.
- Always keep a second SSH session open while you reload `sshd`.
- If your provider has a “console”/KVM recovery path, confirm you can access it *before* you break remote access.
2.2 fail2ban: make brute force a waste of time
SSH will get hammered. Not “maybe.” It will.
fail2ban is not a silver bullet, but it turns commodity noise into self-cleaning logs.
We tuned it in two layers:
- **Normal aggressiveness:** 3 strikes within 24 hours.
- **Long-tail aggressiveness:** 2 strikes within 7 days (catches slow-rolling scanners).
Example structure (conceptual—adapt to your distro):
/etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
port = ssh
backend = systemd
# Baseline: 3 hits in 24h
findtime = 24h
maxretry = 3
bantime = 24h
# Ban action should match your firewall stack
banaction = ufw
Then add a second jail for “slow brute force” (implementation varies; one approach is a separate filter/jail targeting auth failures with longer findtime). Conceptually:
[sshd-long]
enabled = true
port = ssh
backend = systemd
findtime = 7d
maxretry = 2
bantime = 30d
banaction = ufw
Validate with:
sudo fail2ban-client status
sudo fail2ban-client status sshd
**Opinionated note:** If you have WireGuard and plan to close SSH publicly, fail2ban becomes less critical. But it’s still useful during transition, and it protects you if you temporarily re-open SSH.
2.3 UFW: explicit allowlist, not wishful thinking
I prefer “default deny inbound” because it forces you to enumerate what’s actually allowed.
Example (before WireGuard consolidation):
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Temporary: allow SSH while we’re still using it publicly
sudo ufw allow 22/tcp comment 'SSH (temporary)'
# If you host HTTPS directly
sudo ufw allow 443/tcp comment 'HTTPS'
# WireGuard (eventually the only public ingress besides HTTPS)
sudo ufw allow 51820/udp comment 'WireGuard'
sudo ufw enable
sudo ufw status verbose
**Rule of thumb:** if you can’t explain *why* a port is open, it shouldn’t be.
2.4 Kernel hardening: small sysctls, real signal
Two settings I like early:
- `log_martians`: helps detect suspicious routing packets.
- `fs.suid_dumpable=0`: reduces the chance of dumping sensitive memory via core dumps under certain conditions.
Example /etc/sysctl.d/99-hardening.conf:
# Log suspicious packets
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# Don’t allow setuid programs to produce core dumps
fs.suid_dumpable = 0
Apply:
sudo sysctl --system
This isn’t “maximum security.” It’s “remove footguns and increase visibility.”
2.5 Passwordless sudo for a service account (yes, but do it deliberately)
This one is controversial.
For an autonomous agent server, there’s a real operational tension:
- you want the agent to self-heal and manage services,
- but you don’t want a compromised agent process to become root.
What we did was create (or use) a dedicated service/admin account (e.g., openclaw) and allow passwordless sudo for a *minimal* set of commands (ideally), or full sudo only if you accept the risk.
Preferred: lock it down to specific commands:
/etc/sudoers.d/openclaw
# Example: allow only service management for specific units
openclaw ALL=(root) NOPASSWD: /bin/systemctl restart openclaw*, /bin/systemctl status openclaw*
If you must do broader permissions, treat it as a temporary phase while you build a better privilege model (polkit rules, separate automation runner, etc.).
---
3) Cloud Provider Firewall: the rules above your rules
Here’s a mistake people make (including me, historically):
“I set UFW, so I’m protected.”
Cloud firewalls exist *above* your server firewall. They can block traffic before it ever hits your NIC. That’s good.
We used the **cloud hosting API** to manage firewall rules programmatically. The goal wasn’t just “click in a UI once.” It was:
- reproducible changes,
- auditable diffs,
- automation-friendly updates.
3.1 Set up API access for automation
Create API credentials in your cloud provider dashboard and store them securely (not in shell history, not in a public repo).
Use placeholders in scripts:
- `[YOUR-HOSTING_PROVIDER-API-USER]`
- `[YOUR-HOSTING_PROVIDER-API-PASSWORD]`
- `[YOUR-HOSTING_PROVIDER-API-TOKEN]` (if token-based)
Store in a root-readable secret file or a dedicated secrets manager:
- `/root/.secrets/hosting-api-key.txt` (example)
3.2 Remove unnecessary ports (goodbye, port 80)
If you’re not serving HTTP, close it.
If you *are* serving HTTP, consider redirecting 80→443 at a reverse proxy *only if needed*. In many hardened setups, you can skip 80 entirely and use:
- strict HTTPS links,
- HSTS,
- and a proper certificate flow.
In our case, we removed 80/tcp at the provider firewall.
**Why provider-level removal matters:**
- it reduces noise in your logs,
- it reduces the chance of misbinding a service to 0.0.0.0,
- it protects you even if UFW is disabled or misconfigured.
3.3 Programmatic firewall management (pattern)
I’m not including exact endpoints/IDs from our environment (public write-up rule), but the pattern is:
- List resources (datacenters, servers, NICs).
- Identify the NIC attached to your public IP.
- Fetch firewall rules for that NIC.
- Apply a desired allowlist:
- `443/tcp` (HTTPS)
- `51820/udp` (WireGuard)
- (temporary) `22/tcp` during migration
A minimal pseudo-script outline:
export HOSTING_PROVIDER_USER='[YOUR-HOSTING_PROVIDER-API-USER]'
export HOSTING_PROVIDER_PASS='[YOUR-HOSTING_PROVIDER-API-PASSWORD]'
# 1) discover resources
# 2) compute desired firewall allowlist
# 3) PUT/PATCH firewall rules
# 4) verify effective rules
**Lesson learned:** API rate limits and eventual consistency are real. Build scripts that:
- retry with backoff,
- verify after changes,
- and log what changed.
---
4) Docker Service Isolation: if it must be messy, keep it contained
We moved “risky-but-useful” services into Docker:
- a media server,
- a download/torrent client,
- and supporting bits.
Docker doesn’t magically secure things, but it gives you primitives:
- separate networks,
- controlled port publishing,
- constrained filesystem mounts,
- and easy rebuild/update paths.
4.1 The big rule: torrent traffic must never escape the VPN
If you run torrents, your threat model changes. Even if you’re legally fine, you’re now:
- generating noisy traffic patterns,
- interacting with untrusted peers,
- and increasing the consequences of IP leakage.
So we forced torrent traffic through a **WireGuard/VPN tunnel interface**.
The principle:
- The container’s default route goes through the VPN interface.
- If the VPN drops, **network should fail closed**, not “fall back to WAN.”
4.2 Practical pattern: VPN container + dependent services
A common approach:
- one container runs WireGuard (or a VPN client),
- other containers share its network namespace (`network_mode: "service:vpn"`),
- firewall rules (iptables/nftables) block non-tunnel egress.
High-level docker-compose.yml sketch:
services:
vpn:
image: example/wireguard-client:latest
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
volumes:
- ./wireguard:/config:ro
downloader:
image: example/download-client:latest
network_mode: "service:vpn"
depends_on:
- vpn
volumes:
- ./data:/data
**Important:** Don’t publish downloader web UIs to the public interface. Either:
- bind them only to `127.0.0.1` and access via WireGuard, or
- don’t publish ports at all and access through the VPN network.
4.3 Containers aren’t a security boundary (but they help)
My posture:
- Docker is for isolation and manageability.
- Security comes from **network policy** (VPN-only access) and **least exposure**.
---
5) Automated Vulnerability Scanning: make drift visible
You can harden perfectly at 3pm and be vulnerable again at 3am because:
- an upstream image shipped a bad update,
- a dependency got a CVE,
- or you pinned a version that silently went stale.
So we set up continuous scanning in three layers:
- **Docker image update checker**: “Are there newer images than what I’m running?”
- **GitHub release comparison**: “Did upstream ship a new release?”
- **CVE lookups via OSV**: “Do my dependencies map to known vulnerabilities?”
5.1 OSV API: fast, scriptable CVE intelligence
OSV (Open Source Vulnerabilities) provides a clean API for querying vulnerabilities by package/ecosystem.
Example (conceptual curl):
curl -s https://api.osv.dev/v1/query \
-H 'Content-Type: application/json' \
-d '{
"package": {"name": "openssl", "ecosystem": "Ubuntu"},
"version": "[INSTALLED-VERSION]"
}'
5.2 Run it every 12 hours (cron)
We scheduled a 12-hour cron job for the scanner. The job should:
- collect versions,
- compare with known-good baselines,
- query OSV,
- and output a summary.
Skeleton cron entry:
# Twice daily vulnerability scan
0 */12 * * * /usr/local/bin/security-scan.sh >> /var/log/security-scan.log 2>&1
**Opinionated note:** Don’t make the scanner “auto-patch” everything. Auto-patching is how you turn a security program into an uptime incident generator. Alert first, patch intentionally.
---
6) Infrastructure Monitoring: treat “boring” as a feature
Security without monitoring is performance art.
We added cloud provider monitoring policies for:
- **CPU**: sustained high CPU can signal runaway processes, crypto-miners, or indexing jobs gone wild.
- **RAM**: memory pressure can crash services or cause weird behavior.
- **Disk**: the silent killer. Logs and downloads fill disks; filled disks break databases; broken databases break your day.
- **Port monitoring**: ensure the *right* ports are reachable and the *wrong* ports aren’t.
- **Process/service monitoring**: if core services die, alert.
- **Email alerts**: route alerts to a mailbox you actually read.
Thresholds depend on your workload, but a pragmatic baseline is:
- CPU > 90% for 10–15 minutes
- RAM > 90% for 10–15 minutes
- Disk > 80% warning, > 90% critical
**Lesson learned:** monitoring isn’t about catching hackers like in movies. It’s about catching:
- misconfigurations,
- failing disks,
- runaway containers,
- expired certificates,
- and “why is my agent stuck?”
Attackers benefit from your silence. Monitoring is you refusing to be silent.
---
7) WireGuard Zero-Trust Access: move the perimeter to *identity*
This was the centerpiece: stop exposing internal services to the public internet.
The model:
- Public internet gets **only**:
- WireGuard (`51820/udp`)
- HTTPS (`443/tcp`) (optional, for a public landing page or reverse proxy)
- Everything else is reachable only over the VPN.
7.1 Set up the WireGuard server
Install WireGuard on the server and generate keys.
Example (commands vary by distro):
sudo apt-get update
sudo apt-get install -y wireguard
wg genkey | tee /etc/wireguard/server.key | wg pubkey | tee /etc/wireguard/server.pub
Create /etc/wireguard/wg0.conf (redacted example):
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = [REDACTED]
# Enable forwarding / NAT as needed
PostUp = [REDACTED IPTABLES/NFT RULES]
PostDown = [REDACTED IPTABLES/NFT RULES]
[Peer]
PublicKey = [CLIENT-1-PUBKEY]
AllowedIPs = 10.8.0.2/32
Enable:
sudo systemctl enable --now wg-quick@wg0
sudo wg show
7.2 Generate client configs (and don’t leak them)
Client config example:
[Interface]
PrivateKey = [REDACTED]
Address = 10.8.0.2/32
DNS = 10.8.0.1
[Peer]
PublicKey = [SERVER-PUBKEY]
Endpoint = example.com:51820
AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 25
7.3 Split tunnel vs full tunnel
We used **split tunneling** for admin access so that:
- only traffic destined for `10.8.0.0/24` goes over the VPN,
- the rest of the client’s internet uses normal routing.
This is a practical compromise for day-to-day operations.
If you want maximum privacy, you can do full tunnel (AllowedIPs = 0.0.0.0/0, ::/0), but then you inherit a different set of problems (DNS, exit routing, bandwidth, troubleshooting complexity).
7.4 Move ALL services behind VPN
This is the part that changes your security posture overnight.
Actions:
- Bind service UIs to the WireGuard interface or localhost.
- Remove provider firewall rules for application ports.
- Remove UFW rules for application ports.
And finally:
- **Close public SSH** (after verifying you can reach SSH over WireGuard).
That last parenthetical is not optional.
**What went wrong (and why it matters):**
- The temptation is to close ports immediately.
- The correct order is:
- bring up WireGuard,
- verify connectivity,
- verify you can recover if it fails,
- then close SSH and app ports.
Zero Trust is not a vibe. It’s a sequence of reversible steps.
---
8) The Result: from 5+ open ports to 2
The “before” picture (typical):
- `22/tcp` SSH
- `80/tcp` HTTP
- `443/tcp` HTTPS
- `8080/tcp` web UI
- `9091/tcp` download UI
- `32400/tcp` media server
- plus whatever else you forgot was listening
The “after” picture:
- `51820/udp` WireGuard
- `443/tcp` HTTPS (optional, if you truly need something public)
Everything else:
- bound to `10.8.0.1` (VPN), `127.0.0.1`, or not published at all.
This is the real win: your attack surface isn’t “hardened,” it’s **gone**.
What’s left to do (the honest backlog)
Hardening is never “done.” The shortlist I’d do next:
- **AppArmor/SELinux profiles** for high-risk services.
- **Remote log shipping** (e.g., to an append-only store) so intruders can’t erase evidence.
- **Immutable infrastructure**: rebuild from code, not hand-edited snowflakes.
- **Secrets management**: move tokens out of disk files into a proper vault.
- **2FA everywhere** for dashboards that remain public (preferably none).
---
9) Security Scanning Pipeline: automate the paranoia
Once you have a baseline, automation keeps it from drifting.
We wired up a pipeline conceptually like this:
9.1 Llama Guard 4 for content classification
If your agent ingests external text (issues, PRs, forum posts, chat logs), you want a filter before that text becomes “instructions.”
We used a content classifier step (Llama Guard 4) to label inbound content and flag:
- prompt injection patterns,
- suspicious requests for secrets,
- social engineering.
Key idea: **don’t let untrusted text become execution context without a gate.**
9.2 Automated security scans every 6 hours
In addition to the 12-hour vuln scan, we scheduled lighter “health/security drift” checks every 6 hours:
- unexpected open ports,
- changed firewall rules,
- new listening services,
- docker container changes.
This catches the “I installed something and forgot” class of incidents.
9.3 HIBP credential checks
If your workflows involve credentials, periodically check whether they’ve appeared in known breaches.
Important notes for doing this responsibly:
- Use k-anonymity APIs when possible.
- Never upload raw passwords anywhere.
- Prefer checking hashed prefixes.
Public write-up placeholder:
- `[HIBP-API-KEY]`
9.4 LinPEAS weekly audits
LinPEAS is noisy, but it’s good at finding:
- weird permissions,
- privilege escalation vectors,
- misconfigurations you stopped seeing.
Run it weekly and treat it like a diff tool: what changed since last week?
---
10) Lessons Learned: the stuff I’d tell past-me
10.1 Commit immediately
Every time you make a security change, you’re editing reality.
If your code/config isn’t committed:
- you can’t reproduce it,
- you can’t diff it,
- you can’t roll it back safely.
Security work without version control is just vibes and luck.
10.2 Test the VPN path before closing ports
Make this a ritual:
- Bring up WireGuard.
- Connect a client.
- SSH over the VPN.
- Confirm you can reach service UIs over the VPN.
- Only then close public ports.
If you do it in the wrong order, you don’t get “better security.” You get “an unplanned outage with a side of self-inflicted lockout.”
10.3 Cloud firewalls exist above OS firewalls
Provider firewall rules are your first line.
UFW/nftables on the box is your second.
Docker’s port publishing is a third.
You want all three aligned.
10.4 Rate limits and eventual consistency are part of infrastructure
If you automate firewall changes via API:
- handle rate limits (backoff, retries),
- verify after change,
- don’t assume “200 OK” means “propagated everywhere instantly.”
10.5 Security is a system, not a checklist
The biggest shift wasn’t a sysctl tweak. It was changing the shape of the system:
- from public-by-default,
- to VPN-only by default,
- with monitoring and scanning so drift doesn’t silently undo your work.
That’s the difference between “a hardened box” and “an infrastructure that stays hardened.”
---
Appendix: Quick “After” checklist (redacted)
Use this as a sanity scan after you implement the above.
- [ ] Provider firewall allows only `51820/udp` and `443/tcp` (if needed)
- [ ] UFW default deny inbound
- [ ] SSH password auth disabled
- [ ] SSH reachable only via WireGuard (preferred)
- [ ] fail2ban enabled during transition and/or for VPN-exposed SSH
- [ ] No docker-published ports on public interfaces
- [ ] Monitoring policy enabled (CPU/RAM/Disk/Port/Process)
- [ ] Vulnerability scan cron running (12h)
- [ ] Drift scan cron running (6h)
- [ ] Weekly LinPEAS run scheduled and results reviewed
---
Closing
Hardening an OpenClaw server isn’t about winning a theoretical security contest. It’s about reducing the number of ways your future self can have a very bad day.
In one afternoon, we went from “internet-exposed services and hope” to “two ports, VPN access, scanning, monitoring, and a clear upgrade path.”
If you take only one idea from this post:
Don’t harden by adding more rules. Harden by deleting exposure.