VPS Hardening Guide

Secure your self-hosted server.

SSH hardening, firewall, Fail2ban, auto-updates, Docker port bindings, and AI/MCP agent checks. Seven steps that close the attack surface Trivy and Lynis each miss half of — with a single command to verify all of them at once.

Why image scanners and host auditors each cover half the box

Trivy

Container images + filesystems

Misses: Host config, Docker networking, AI/MCP agents

Lynis

Host OS configuration

Misses: Containers, Docker bindings, AI/MCP agents

Keelix

Host + containers + exposure + AI/MCP

Full coverage

The hardening checklist

Seven steps. Each maps to one or more CIS Benchmark controls and OWASP recommendations. Work through them top to bottom on a fresh server before opening traffic.

01CRIT

SSH: keys only, no root, non-standard port

Generate an ED25519 key pair locally and copy the public key to the server with `ssh-copy-id`. Then in `/etc/ssh/sshd_config`, set `PasswordAuthentication no`, `PermitRootLogin no`, and optionally `Port 2222` (a non-standard port reduces automated scan noise). Restart SSH: `systemctl restart sshd`. Test from a second terminal before closing the first.

ssh-keygen -t ed25519 -C deploy@yourserver
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server
02CRIT

Firewall: allow only what you need

Enable UFW with a deny-all default inbound policy, then open only the ports your services require. If you changed SSH to a non-standard port, open that instead of 22. Note: UFW alone does not protect Docker-published ports — see step 05 for the Docker binding fix.

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp   # or your custom SSH port
ufw allow 443/tcp
ufw enable
03WARN

Fail2ban: block brute-force attempts

Fail2ban monitors auth logs and temporarily bans IPs that exceed a failed-login threshold. After installing, create `/etc/fail2ban/jail.local` (which overrides defaults without breaking package upgrades). A reasonable starting config: `bantime = 1h`, `findtime = 10m`, `maxretry = 3` for the `[sshd]` jail.

apt install fail2ban -y
# create /etc/fail2ban/jail.local with your sshd jail
systemctl enable --now fail2ban
04WARN

Automatic security updates

Vulnerability exploitation rose 34% year-over-year in the Verizon 2025 DBIR — the fastest-growing initial-access vector. Unattended-upgrades on Debian/Ubuntu applies security patches automatically. Review `/etc/apt/apt.conf.d/50unattended-upgrades` to confirm `Unattended-Upgrade::Allowed-Origins` includes the `-security` pocket and that `AutocleanInterval` is set.

apt install unattended-upgrades -y
dpkg-reconfigure --priority=low unattended-upgrades
05CRIT

Docker port bindings: 127.0.0.1, not 0.0.0.0

Every `-p HOST:CONTAINER` mapping Docker publishes binds to 0.0.0.0 by default, inserting iptables rules that bypass UFW entirely. Any service that does not need to be directly public should be bound to 127.0.0.1. In docker-compose.yml: `ports: ["127.0.0.1:6379:6379"]`. Run `docker ps --format 'table {{.Names}}\t{{.Ports}}'` and audit every 0.0.0.0 binding.

# In docker-compose.yml:
ports:
  - "127.0.0.1:6379:6379"   # safe — localhost only
  # not: - "6379:6379"        # exposes to all interfaces
06CRIT

Never expose the Docker daemon on TCP

Port 2375 (Docker daemon unencrypted TCP) grants unauthenticated root-equivalent access to your entire host. Run the daemon on the Unix socket only (the default). If you need remote CI access, use SSH tunnelling (`DOCKER_HOST=ssh://user@host`) instead. Verify: `ss -tlnp | grep 2375` should return nothing.

# Verify daemon is NOT listening on TCP:
ss -tlnp | grep 2375   # should return nothing
# Check DOCKER_HOST:
unset DOCKER_HOST
07WARN

Grade your AI agents and MCP servers

If you run Claude Code, Cursor, Windsurf, or any MCP-enabled AI agent on this server, your `.mcp.json` or `mcp_servers.json` config can expose plaintext API keys and enable auto-approval of all tools — bypassing every agent permission boundary. These risks are invisible to Trivy, Lynis, and any container-only scanner. Keelix grades all three legs of the lethal trifecta on your live deployment.

keelix scan   # grades host + containers + exposure + AI/MCP agents

Keelix verifies all of this in one scan

Keelix scan — example findings
PASSSSH password authentication disabled
PASSUFW active — default deny inbound
CRITRedis bound to 0.0.0.0:6379 — reachable from internet (Docker bypasses UFW)
CRITMCP auto-approval enabled — agent bypasses all tool permission checks
WARNFail2ban not installed — SSH brute-force protection absent
Each finding maps to a CIS Benchmark control or OWASP recommendation.

Gate CI on the posture score

- uses: jakelamon/keelix@v0.1.0
  with:
    fail-below: 80   # block merge if posture score drops below 80
    severity: critical  # always fail on CRIT findings

Frequently asked

What is a VPS hardening checklist?
A VPS hardening checklist is a structured set of configuration steps that reduce the attack surface of a self-hosted server. It typically covers SSH hardening (key-based auth, no root login), a host firewall (UFW or nftables), brute-force protection (Fail2ban), automatic security updates, Docker port bindings, and — for servers running AI agents — MCP server configuration. The goal is to eliminate the most common initial-access vectors before the server goes live.
Does Trivy or Lynis cover the whole server?
Not quite. Trivy scans container images and filesystems for CVEs — it does not check host configuration, firewall rules, Docker port bindings, or MCP agent configuration. Lynis audits the host OS — it does not scan containers or Docker networking, and it produces a prose report rather than a scored, shareable result. Keelix combines host hardening, container checks, outside-in exposure scanning, and AI/MCP agent grading into a single 0–100 posture score.
How do I check if my Docker ports are exposed to the internet?
Run `docker ps --format 'table {{.Names}}\t{{.Ports}}'` to see all port mappings. Any mapping showing `0.0.0.0:PORT->` means Docker has bound that port to all interfaces and bypassed your host firewall (UFW, iptables) entirely. To fix it, change the mapping to `127.0.0.1:HOST_PORT:CONTAINER_PORT`. Then verify from outside with `nmap` or by running `keelix scan` — the outside-in scan shows what's actually reachable, not just what's configured.
What is the difference between SSH password auth and key-based auth?
Password authentication allows any attacker who can reach your SSH port to attempt unlimited guesses (subject to Fail2ban rate limits). Key-based authentication requires possession of the private key, which eliminates brute-force viability entirely. Disable `PasswordAuthentication` in `/etc/ssh/sshd_config` once your key pair is installed, and set `PermitRootLogin no` to prevent direct root access. These two changes together eliminate the most common SSH attack surface.
Should I run Keelix in CI or just before deployment?
Both — but for different purposes. Running `keelix scan` locally before deployment catches misconfiguration before it ships. Running Keelix as a GitHub Action on every push to your infrastructure repo catches regressions the moment they're introduced — before they can be deployed. The two together implement the monitoring-vs-enforcement gate pattern: the Action blocks a merge on critical findings; the manual scan gives you a full posture view on demand.

Verify your hardening checklist in one command.

Free, local-first, Apache-2.0. The scan runs on your box — nothing leaves it.

operator@host — keelix
# install — free & open source (Apache-2.0)
$ curl -fsSL https://keelix.dev/install.sh | sh
$ keelix scan