Security Hardening
Overview of all security controls applied to the OpenClaw VPS.
Firewall
Two firewall layers run in series.
Hetzner Cloud Firewall (network-level)
Managed in Terraform (terraform/modules/hetzner-vps/main.tf). Applied at the hypervisor — traffic is dropped before it reaches the OS.
| Direction | Protocol | Port | Source | Condition |
|---|---|---|---|---|
| Inbound | TCP | 22 | ssh_allowed_cidrs variable | Always |
| Inbound | UDP | 41641 | 0.0.0.0/0, ::/0 | Only if Tailscale on |
| Outbound | TCP | 1–65535 | 0.0.0.0/0, ::/0 | Always |
| Outbound | UDP | 1–65535 | 0.0.0.0/0, ::/0 | Always |
| Outbound | ICMP | — | 0.0.0.0/0, ::/0 | Always |
ssh_allowed_cidrs defaults to ["0.0.0.0/0"]. For tighter access, set it to specific IPs in secrets/inputs.sh, or set it to [] to block public SSH entirely (Tailscale-only mode).
UFW (OS-level)
Base rules configured during cloud-init. Tailscale rule added by Ansible (plays/tailscale.yml) during bootstrap.
| |
IPv6
IPv6 is disabled on all OpenClaw VPS instances at the OS level via sysctl. UFW only manages IPv4; Hetzner Cloud assigns a public IPv6 address to every VPS by default, meaning the server has an unfiltered IPv6 interface exposed to the internet unless explicitly addressed.
Rather than maintaining separate ip6tables rules, we disable IPv6 entirely. This aligns with the current IPv4-only Tailscale + UFW setup.
A sysctl configuration file is written during cloud-init:
| |
| |
Applying to existing servers:
Cloud-init only runs on first boot. To apply this to an existing server:
| |
Verifying:
| |
SSH Hardening
sshd drop-in (/etc/ssh/sshd_config.d/99-hardening.conf)
Written during cloud-init (permissions 0600, owner root):
| |
Key-based authentication only. Root login is disabled; all access is via the openclaw user (passwordless sudo, scoped to specific commands — see Sudo Permissions).
fail2ban (/etc/fail2ban/jail.local)
Installed and started during cloud-init:
| |
Bans IPs after 3 failed attempts within 10 minutes. Ban duration is 1 hour.
Applying to an existing server
Cloud-init only runs on first boot. To apply SSH hardening to a running server, follow the checklist below, then run these commands manually:
| |
Safety checklist for applying to a running server: see SSH Hardening Safety Checklist below.
Sudo Permissions
The openclaw user has passwordless sudo, scoped to specific commands only (no NOPASSWD:ALL).
Containers are managed via docker compose (no sudo needed — the user is in the docker group). The sudo whitelist is for system-level operations only.
Allowed commands
| Command | Purpose |
|---|---|
/usr/bin/tailscale status | View VPN status (used by make status) |
/usr/bin/tailscale up | Re-authenticate Tailscale (used by make tailscale-up) |
/usr/sbin/ufw status | View firewall status (read-only) |
/usr/bin/journalctl * | Read system/service logs for debugging |
/usr/bin/systemctl daemon-reload | Reload systemd after installing units |
/usr/bin/systemctl restart docker | Restart Docker daemon if it crashes |
/usr/bin/fail2ban-client status * | View fail2ban jail status (used by make status) |
Not included by design:
docker *— redundant (user is in thedockergroup) and equivalent to full rootsystemctl restart openclaw-*— these are Docker containers, not systemd services; usedocker compose restartapt upgrade/autoremove— handled byunattended-upgrades
Applying to an existing server
Cloud-init only runs on first boot. To apply scoped sudo on a running server:
| |
Before applying to production: open two SSH sessions, apply in one, and verify the other still works. The Hetzner Cloud console (web VNC) is the safety net if you lock yourself out.
Tailscale
Tailscale provides a WireGuard-based VPN mesh. When enabled, it allows SSH and gateway access over the Tailnet without exposing any ports to the public internet.
Terraform variable:
| Variable | Default | Where set | Description |
|---|---|---|---|
enable_tailscale | false | terraform/envs/prod/terraform.tfvars (copy from terraform.tfvars.example) | Opens Hetzner firewall UDP 41641 |
The auth key is not a Terraform variable. It is passed to Ansible as TAILSCALE_AUTH_KEY (set in secrets/inputs.sh).
Ansible provisioning (ansible/plays/tailscale.yml):
Runs as part of make bootstrap, or standalone via make tailscale-setup. Skipped if TAILSCALE_AUTH_KEY is unset.
| |
If no auth key is set, run make tailscale-up after make bootstrap to authenticate interactively.
Useful commands:
| |
Gateway allowlisting:
The OpenClaw gateway’s allowTailscale: true setting in openclaw.json trusts requests arriving over Tailscale without requiring an explicit token.
Secrets Encryption (SOPS + age)
All secrets are encrypted at rest using SOPS with age keys. The encrypted file secrets/.env.enc is committed to git — safe to share, since only keyholders can decrypt.
How It Works
| Layer | Mechanism |
|---|---|
| At rest (git) | secrets/.env.enc — AES-256-GCM encrypted by SOPS, safe to commit |
| In transit (CI) | GitHub Actions decrypts using SOPS_AGE_KEY secret before make deploy |
| On VPS (docker-compose) | Ansible decrypts .env.enc → .env temporarily, then shred -u after containers start |
| On VPS (OpenClaw native) | Gateway container decrypts .env.enc on-the-fly via SOPS exec provider |
Key Management
- Age key: Generated once with
age-keygen, stored locally insecrets/age-key.txt(gitignored) - GitHub secret: Full content of
age-key.txtstored asSOPS_AGE_KEY - VPS: Age key mounted read-only into gateway container at
/home/node/.config/sops/age/keys.txt
Local Workflow
| |
OpenClaw Native SOPS Provider
The gateway container uses SOPS as an exec-based secrets provider (openclaw.json → secrets.providers.sops_env). This allows OpenClaw to decrypt individual secrets on-the-fly without persisting plaintext on disk.
The encrypted .env.enc is mounted into the container at /home/node/.openclaw/secrets/.env.enc, and the age key is available via SOPS_AGE_KEY_FILE. Individual secrets are extracted via a shell command that decrypts and greps the value:
| |
First-Time Setup Checklist
make secrets-generate-key— createssecrets/age-key.txt- Copy the public key (shown after generation) into
.sops.yamlunderage: make secrets-encrypt— createssecrets/.env.enc- Add
SOPS_AGE_KEYto GitHub Secrets (Settings → Secrets → Actions) — paste the full content ofsecrets/age-key.txt - On VPS: ensure age key exists at
/home/openclaw/.config/sops/age/keys.txt(or setSOPS_AGE_KEY_FILEin env)
GitHub Actions
Validate (validate.yml)
Runs on every PR and push to main. Fails the build if any of these fail:
terraform fmt -check— formattingterraform validate— Terraform validityshellcheck— shell script lintingansible-playbook --syntax-check— Ansible syntax- Checkov IaC scan (soft fail — reports but doesn’t block)
Deploy (deploy.yml)
Triggers on push to main when docker/, docker-compose.yml, ansible/, or secrets/.env.enc change.
- Connects to Tailscale using OAuth credentials (
TAILSCALE_OAUTH_CLIENT_ID,TAILSCALE_OAUTH_CLIENT_SECRET) with tagtag:ci-runner - Writes
SSH_PRIVATE_KEYto a temp key file (0600) - Installs SOPS, decrypts
secrets/.env.encusingSOPS_AGE_KEYsecret - Runs
make deploy(ormake deploy REBUILD=1if Docker files changed)
Required GitHub secrets: TAILSCALE_OAUTH_CLIENT_ID, TAILSCALE_OAUTH_CLIENT_SECRET, SSH_PRIVATE_KEY, SERVER_IP, SOPS_AGE_KEY.
Template: .github/workflows/deploy.yml.example — copy to deploy.yml to enable. See GitOps auto-deploy for full setup.
Rollback (rollback.yml)
Manual workflow dispatch. Takes a Git SHA, checks out that commit, decrypts secrets with SOPS, and runs make deploy REBUILD=1 to redeploy from that point.
Automatic Security Updates
unattended-upgrades is installed and enabled during cloud-init. It applies OS security patches automatically without manual intervention.
SSH Hardening Safety Checklist
Use this when applying SSH hardening to a server that is already running.
Pre-flight (do BEFORE touching anything)
- Open two SSH sessions to the VPS as the
openclawuser — one to apply changes, one as a bail-out lifeline - Verify
openclawuser SSH works — hardening setsPermitRootLogin no, so root SSH will be lost - Verify
sudo -n tailscale statusworks (scoped passwordless sudo is configured in cloud-init) - Note your current IP —
echo $SSH_CLIENT— so you can unban yourself if fail2ban triggers - Confirm
ssh-add -lis non-empty, or that you’re using-i <keyfile>directly
Apply changes (keep the second session open throughout)
- Write
/etc/ssh/sshd_config.d/99-hardening.confwith0600permissions owned byroot - Write
/etc/fail2ban/jail.local - Install fail2ban:
sudo apt-get install -y fail2ban - Validate sshd config:
sudo sshd -t— if this fails, do not proceed - Reload sshd:
sudo systemctl reload ssh - Verify the second session still responds
- Reload fail2ban:
sudo systemctl reload fail2ban
Post-flight verification
- Open a third terminal and confirm a fresh SSH session connects
- Verify root login is rejected:
ssh root@<ip>should returnPermission denied - Check fail2ban:
sudo systemctl status fail2banandsudo fail2ban-client status sshd
Rollback (if locked out)
- Use Hetzner Cloud console (web VNC) to regain access
- Remove
/etc/ssh/sshd_config.d/99-hardening.confand runsudo systemctl reload ssh - Unban your IP:
sudo fail2ban-client set sshd unbanip <YOUR_IP>