Your agent server goes live and, within hours, something you didn't invite is knocking on its ports. That's the normal state of any public IP, and an AI agent box has more to lose than a static site: it holds LLM API keys, database passwords and OAuth tokens, and it runs tools that can send email or spend money.
This is the checklist I'd run on a fresh Ubuntu VPS before putting an agent stack such as n8n, OpenClaw or a local-model chat UI on it. I checked each step against Ubuntu, OpenSSH, Docker, fail2ban and Compose documentation on 2026-10-08 and re-checked the Docker, ufw and sshd claims on 2026-10-09. I didn't have a live VPS in this session, so the shell commands are labelled unrun. The one thing I did run is a compose audit script, with real output below.
This is a baseline, not a guarantee. It does not cover compliance regimes, intrusion detection or a hostile insider. For deployment walkthroughs see self-hosting OpenClaw on a VPS, n8n on a VPS with Docker and a private ChatGPT with Ollama.
What should you do in the first 15 minutes?
In this order, because each step depends on the one before:
- Create a non-root user with sudo.
- Put your SSH public key on it and confirm you can log in with it in a second terminal.
- Disable password login and root login over SSH.
- Turn on the firewall, allowing SSH first.
- Enable automatic security updates.
Skip nothing and keep your original session open until step 3 is verified. Ubuntu's OpenSSH page warns that a configuration mistake can lock you out when SSH is your only way in, and advises keeping an existing session open while you test.
Need a server to practise on? Any provider works. I use Hostinger for several guides on this site, for example the KVM 2 plan, and their plan page lists firewall management, weekly backups and manual snapshots on every plan (checked 2026-10-09). Some links on this page are affiliate links; they cost you nothing and I only link tools I'd mention anyway. Hetzner, DigitalOcean and Linode give you the same Ubuntu image, and every command below is provider-neutral.
How do you set up SSH keys and a non-root user?
On your own computer, generate a key. Ubuntu's docs recommend ed25519:
ssh-keygen -t ed25519
On the server as root (unrun):
adduser deploy
usermod -aG sudo deploy
Back on your computer, install the key (this is ssh-copy-id from Ubuntu's OpenSSH page), then open a second terminal and log in as that user:
ssh-copy-id deploy@your.server.ip
ssh deploy@your.server.ip
Only when that works, create a drop-in file. Ubuntu's sshd_config includes /etc/ssh/sshd_config.d/*.conf first, and OpenSSH uses the first value it reads for most directives, so a snippet overrides the defaults without editing the main file. Create /etc/ssh/sshd_config.d/99-hardening.conf:
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
What these do, from the sshd_config man page: PasswordAuthentication defaults to yes, PermitRootLogin defaults to prohibit-password (keys allowed, passwords not) and no blocks root entirely, KbdInteractiveAuthentication defaults to yes and is a second route to a password prompt so turn it off too, AllowUsers limits logins to the names listed, and MaxAuthTries defaults to 6. Then validate before restarting, per Ubuntu's docs:
sudo sshd -t
sudo systemctl restart ssh.service
sshd -t checks the config syntax and stops you from restarting into a broken SSH daemon. Keep the first session open, and test a fresh login in another terminal before closing anything. If a cloud provider offers a web console, find it now, while you still have access.
Which firewall rules do you need?
Two layers. A provider-level firewall (outside your server) and ufw on the host. Allow SSH before you enable ufw. These are standard ufw commands; the Ubuntu page I read documents enable, allow and status, and the ufw man page documents default and limit:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Per the man page, limit denies connections if an IP address attempts 6 or more connections within 30 seconds, which is a cheap brute-force brake. Open only what the stack needs. A typical agent server needs 22, 80 and 443 and nothing else.
Now the part that catches almost everyone.
Why does Docker bypass your firewall?
Docker's own install page for Ubuntu warns about it: if you use ufw or firewalld, ports published by Docker bypass your firewall rules. The packet-filtering page explains why: container traffic is routed in the nat table and diverted before it reaches the chains ufw uses. So this looks safe and isn't:
sudo ufw deny 11434
docker run -d -p 11434:11434 ollama/ollama # reachable from the internet anyway
That second line is close to the exact command in Ollama's Docker docs, and Ollama has no login. The same goes for n8n on 5678:5678, Flowise, vector databases and admin dashboards. Docker's port-publishing docs give the fix: without a host address a port binds to all addresses, but -p 127.0.0.1:8080:80 makes it reachable only from the host. In Compose:
ports:
- "127.0.0.1:5678:5678"
Better still, publish nothing from the agent containers. Put them and a reverse proxy (Caddy, nginx, Traefik) on the same Docker network and publish only 80 and 443 from the proxy. Two caveats from the docs: the port-publishing page says a port bound to one host address can still be reached through other interfaces if IP forwarding is enabled, and releases older than Docker 28.0.0 let hosts on the same layer-2 segment reach ports published to localhost. I didn't verify the details of the DOCKER-USER chain, so I don't cover it here; Docker's packet-filtering page is where to read about it.
Check from outside, not from the server. From your laptop:
nmap -Pn -p 22,80,443,5678,8080,11434 your.server.ip
Only the ports you meant to open should show as open. Run it again after every deploy that touches a compose file.
How do you audit a compose file for exposure?
I wrote a small script that reads a compose file with a YAML parser and flags the mistakes above: ports on all interfaces, docker.sock mounts, privileged mode, host networking, literal secrets and rolling image tags. It's a smoke test, not a scanner, and it reads the raw file without expanding variables.
import re
import sys
import yaml
SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASS)", re.I)
def port_findings(svc, ports):
out = []
for p in ports:
if isinstance(p, dict):
host_ip = p.get("host_ip", "")
text = f'{host_ip}:{p.get("published")}:{p.get("target")}'
else:
text = str(p)
parts = text.split(":")
host_ip = parts[0] if len(parts) == 3 else ""
if host_ip in ("127.0.0.1", "::1", "[::1]"):
continue
out.append(f"{svc}: port '{text}' publishes on all interfaces (Docker bypasses ufw for this)")
return out
def main(path):
doc = yaml.safe_load(open(path))
findings = []
for name, svc in (doc.get("services") or {}).items():
findings += port_findings(name, svc.get("ports") or [])
for v in svc.get("volumes") or []:
if "docker.sock" in str(v):
findings.append(f"{name}: mounts docker.sock (root-equivalent control of the host)")
if svc.get("privileged"):
findings.append(f"{name}: privileged: true")
if svc.get("network_mode") == "host":
findings.append(f"{name}: network_mode: host (every listening port is on the host)")
env = svc.get("environment") or {}
if isinstance(env, list):
env = dict(e.split("=", 1) if "=" in e else (e, "") for e in env)
for k, v in env.items():
if SECRET_NAME.search(k) and v and not str(v).startswith("${"):
findings.append(f"{name}: {k} has a literal value in the compose file")
image = str(svc.get("image", ""))
if image and (":" not in image.split("/")[-1] or image.endswith(":latest") or image.endswith(":main")):
findings.append(f"{name}: image '{image}' is unpinned (rolling tag)")
print(f"== {path}")
if not findings:
print("no findings")
for f in findings:
print(" -", f)
return len(findings)
if __name__ == "__main__":
total = sum(main(p) for p in sys.argv[1:])
sys.exit(1 if total else 0)
I ran it on a deliberately bad file and on the n8n stack from my n8n guide. Real output:
== bad/compose.yaml
- ollama: port '11434:11434' publishes on all interfaces (Docker bypasses ufw for this)
- ollama: image 'ollama/ollama' is unpinned (rolling tag)
- n8n: port '5678:5678' publishes on all interfaces (Docker bypasses ufw for this)
- n8n: N8N_ENCRYPTION_KEY has a literal value in the compose file
- n8n: DB_POSTGRESDB_PASSWORD has a literal value in the compose file
- n8n: image 'n8nio/n8n:latest' is unpinned (rolling tag)
- agent: mounts docker.sock (root-equivalent control of the host)
- agent: privileged: true
== n8n/compose.yaml
- caddy: port '80:80' publishes on all interfaces (Docker bypasses ufw for this)
- caddy: port '443:443' publishes on all interfaces (Docker bypasses ufw for this)
The good stack shows only the proxy's 80 and 443, which is exactly the intended exposure. Anything else in that list would be a decision to explain.
What about the Docker socket and the docker group?
Docker's security docs say the daemon needs root unless you use rootless mode, and that only trusted users should control it. They also describe how a container started with a host directory mounted can change host files without restriction. My inference from that, not a quote: anyone who can talk to the Docker socket effectively has root on the machine. So:
- Don't add your agent user to the
dockergroup unless you accept that it is root-equivalent. - Don't mount
/var/run/docker.sockinto an agent or workflow container. A prompt-injected agent with the socket can start a privileged container. Some reverse proxies (Traefik, in n8n's own sample) mount it with:ro. I haven't verified what a read-only flag changes about API access, so treat the socket as sensitive either way and prefer Caddy or nginx with a static config. - Never expose the daemon API over TCP. Docker's docs say exposing it over HTTP without TLS is not permitted.
- Set memory limits so a runaway agent loop doesn't take the host with it. Docker warns the host's out-of-memory killer can take down the daemon and other critical processes, so
-m/mem_limiton heavy services is cheap insurance.
How should you handle secrets and API keys?
Start with what Docker says about environment variables: they're visible to every process in the container, are hard to track and can end up in logs during debugging. Compose secrets are granted per service and mounted as files under /run/secrets/<name>. A minimal example from Docker's docs:
services:
app:
image: myapp:latest
secrets:
- api_key
secrets:
api_key:
file: ./api_key.txt
Not every tool can read from a file, though. n8n, for instance, takes most settings as environment variables (some accept a _FILE suffix; n8n's database docs say the Postgres variables do). For those, a .env file next to the compose file with chmod 600 .env, owned by your deploy user and ignored by git, is the realistic baseline. Also:
- One key per service, so you can revoke one without breaking others.
- Provider-side spend limits and scopes. An LLM key with no cap turns a leak into a bill. Set the cap in the provider console, not in your own code.
- Back up your encryption keys separately. If a tool encrypts stored credentials with a key you set, keep a copy in a password manager, or restored backups will be unreadable.
- Don't paste keys into chats with the agent, and scrub them from logs and execution history if a workflow ever echoes them.
How do you keep the server patched?
Ubuntu's server docs say unattended-upgrades is installed by default and enables automatic security updates right after install; it runs daily via two settings in /etc/apt/apt.conf.d/20auto-upgrades. Install it if missing and test with a dry run:
sudo apt install unattended-upgrades
sudo unattended-upgrade -v --dry-run
Reboot behaviour lives in /etc/apt/apt.conf.d/50unattended-upgrades. Unattended-Upgrade::Automatic-Reboot defaults to false. Kernel updates won't take effect until you reboot, so either schedule a reboot window or check /var/run/reboot-required weekly. If you enable automatic reboots, make sure your containers have restart: unless-stopped so the stack comes back.
apt only patches the OS. Container images are your job: pin versions, read release notes, update on purpose, and rebuild when a base image has a security fix.
Is fail2ban worth installing?
A little. Fail2ban scans logs such as /var/log/auth.log and bans IPs with too many failed logins by updating firewall rules. Its README is honest about limits: it lowers the rate of failed attempts but "cannot eliminate the risk presented by weak authentication", and recommends keys or two-factor. With password login disabled, brute force can't succeed, so fail2ban mostly reduces log noise and load. If you want it:
sudo apt install fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
I'd write overrides in /etc/fail2ban/jail.local rather than editing jail.conf (common practice; the README I read didn't cover it), and confirm the sshd jail is active with the status command above. Don't set an aggressive ban on shared office IPs, and don't ban yourself.
Which risks are specific to AI agent servers?
This is where an agent box differs from a plain web server.
Exposed webhooks. Triggers like n8n's Webhook node give your workflow a public URL. n8n's docs list Basic, Header and JWT authentication plus an IP allowlist that returns 403 to other addresses; leaving the field blank means anyone can call the URL. Use header auth at least. Test that a call without the header gets a 403.
Exposed admin surfaces. Editors and chat UIs (n8n on 5678, Open WebUI on 8080, Ollama on 11434) belong behind the proxy with their own login. Disable open sign-up on anything multi-user.
Keys with side effects. An email, payment or cloud-admin key held by an agent is the real prize. Give it the narrowest scope, a spend or rate cap, and a human approval step for irreversible actions.
Untrusted input steering tools. Anything an agent reads (emails, tickets, web pages, uploaded files) can contain instructions. Server hardening doesn't fix that; tool permissions do. Read prompt injection for tool-using agents, and for tool servers the MCP server security checklist. For a case study of an assistant with broad access, see OpenClaw's security risks.
Egress. Outbound traffic is allowed in the ufw rules above. If an agent is compromised, it can send data out. If your threat model needs it, restrict outbound destinations at the provider firewall to the LLM API, package mirrors and your own services. That takes real maintenance, so it's optional for most small setups.
Logs. Execution histories and chat memory can store customer messages and keys that passed through a workflow. Set retention, and restrict who can open the editor.
How should you handle backups?
Three layers: the provider's snapshots (Hostinger's plan page lists weekly backups and manual snapshots on all plans; take a manual one before big changes), your own database dumps and exports copied off the server, and a copy of the secrets that the dumps depend on. Then do the step everyone skips: restore onto a spare server once and write down what broke. A backup you haven't restored is a hope.
The checklist
- Non-root user with sudo, key login verified from a second terminal
-
PasswordAuthentication no,PermitRootLogin no,AllowUsersset,sshd -tpasses - Provider firewall and ufw: deny incoming, allow 22, 80, 443 only
- No container ports published on all interfaces except the proxy; verified with an outside
nmap - No
docker.sockmounts, noprivileged, no Docker API over TCP - Secrets out of git and out of compose files,
.envat mode 600, spend limits on every API key -
unattended-upgradesdry run passes; reboot plan decided; images pinned - Webhooks authenticated and tested for a 403 without credentials
- Sign-up disabled on chat UIs and editors
- Off-server backups, restore tested, encryption keys stored separately
- fail2ban optional, never the only control



