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 scans container images for CVEs. Lynis audits the host OS against CIS and other configuration benchmarks. Both are excellent at what they do. The problem is that a self-hosted server running Docker is neither purely a container stack nor purely a host — it is both, plus an internet-facing network boundary, plus (increasingly) a runtime environment for AI agents and MCP servers.
Trivy will not tell you your Redis container is bound to 0.0.0.0 and reachable from the internet because Docker bypassed UFW. Lynis will not tell you your .mcp.json has plaintext API keys or that your Claude Code agent is running with auto-approval enabled. Keelix runs all of these checks together and folds the results into a single 0–100 posture score.
Container images + filesystems
Misses: Host config, Docker networking, AI/MCP agents
Host OS configuration
Misses: Containers, Docker bindings, AI/MCP agents
Host + containers + exposure + AI/MCP
Full coverage
Unpatched software and misconfigured services are not theoretical risks — they are the actual entry points attackers use. The Verizon 2025 DBIR documented a 34% year-over-year rise in exploit-based initial access, making vulnerability exploitation the fastest-growing breach vector. The checklist below addresses the most common initial-access vectors on self-hosted Linux servers running Docker.
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.
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
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
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
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
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
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
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
Each step above is a configuration you have to trust yourself to apply correctly and re-verify after every deployment. Keelix runs 93 deterministic checks across all seven categories — SSH config, host firewall state, Fail2ban, package freshness, Docker port bindings, daemon exposure, and AI/MCP agent configuration — and produces a 0–100 posture score with per-finding severity and CIS control mappings.
Run it before you go live. Gate CI on the exit code. The passport report shows you exactly which steps passed, which failed, and what to fix — so you never have to wonder if the hardening checklist is actually in effect.
Gate CI on the posture score
Manual checklists drift. The same hardening you applied on initial setup can be quietly undone by a config change, a new docker-compose service, or an updated MCP server. Running Keelix as a GitHub Action on every infrastructure push catches regressions before they reach production.
- uses: jakelamon/keelix@v0.1.0
with:
fail-below: 80 # block merge if posture score drops below 80
severity: critical # always fail on CRIT findingsThe action exits non-zero on any critical finding or when the posture score drops below your configured threshold — blocking the merge before a misconfigured service can be deployed. See how to fail CI on security findings for the full gate pattern.
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.
Related
- Is my Docker container exposed to the internet?
- MCP Security — grade the AI agents and MCP servers on your box
- Harden Docker — container-level hardening checklist
- Keelix vs Lynis — host auditor vs whole-box posture score
- Keelix vs Trivy — CVE scanning vs whole-box posture
- GitHub Action — gate CI on posture score
Verify your hardening checklist in one command.
Free, local-first, Apache-2.0. The scan runs on your box — nothing leaves it.