Skip to main content

Secure Your AI Agent's VPS: Close Port 22 Without Tailscale

· 23 min read
Vadim Nicolai
Senior Software Engineer

To close port 22 without a VPN: put SSH behind an outbound-only tunnel with an identity check at the edge, verify the new path, then delete the old port-22 firewall rule. Nothing listens for inbound connections. No mesh VPN client has to share the laptop.

An exposed SSH port is not the likeliest way your AI agent's VPS gets owned. It is the likeliest way you lose the ability to fix the machine.

The standard recipe disagrees. Keep the SSH port shut to the world, open it to your own address, install a mesh VPN when you need to get in from anywhere. Its first half solves a smaller problem than its authors think. What I would reach for instead is Cloudflare Tunnel with Cloudflare Access in front. Not because the tunnel is clever, but because an outbound-only connection plus an identity check takes your ISP's address pool and your corporate VPN client out of the security model.

Two results reframed it. In a simulated attack-range evaluation, Llama 3 70B and 405B "efficiently identify network services and open ports in their network reconnaissance" — then failed to gain initial access across 20 and 23 test runs respectively, with attempts to execute exploits "entirely unsuccessful" (Dubey et al., 2024). The reconnaissance worked. The breach did not.

Over a comparable window, a benchmark of ten agent-specific confidentiality attacks — credential leak from an unsanitised snapshot, a skill repurposed as a covert exfiltration channel, system prompt extraction, sandbox escape via the file system — succeeded on both attack paths, 3/3, in every case (arXiv:2606.23075, 2026).

Those studies measure different things, and I refuse to overread them. One asks whether a frontier model can break into a hardened host from outside. The other asks whether an agent already running can be turned against its own assets. But the pair moves the question. The reason to close the port is not that scanners will eventually get through. It is that every incident response begins with "I can reach my machine" — and that is a security property, not an operational convenience.

Why an IP Allowlist Fails the Moment It Matters​

An operator runs an agent on a small VPS. SSH is permitted from exactly one source: the public address of the home connection. One morning SSH times out while the site the VPS serves keeps answering. The machine is up; the SSH port is silent.

The address moved, because the ISP put the connection behind carrier-grade NAT. That range is not an accident of anyone's configuration. Shared Address Space is 100.64.0.0/10, allocated specifically for carrier-grade NAT, and the RFC that defines it is blunt: "CGN service requires non-overlapping address space on each side of the home NAT and CGN," and entities using the range for other purposes "are likely to experience problems" (RFC 6598).

Host firewall rules are per-source by construction — that is what a rule is (ufw). When the source changes, the rule does not degrade gracefully. It fails closed, on the machine that holds live API keys and executes work nobody reviews in real time.

The expensive part is diagnostic, not cryptographic. From outside, a dead daemon, a changed firewall rule and a moved address are indistinguishable: one connection attempt, one timeout, and no way to tell them apart without console access.

It gets worse when you generalise the allowlist into a policy, because an address is a proxy variable, and proxies carry correlations you did not intend. In a study of browser-agent task runs, GPT-3.5 completed 40.2% of tasks with a 16.5% success rate from US Windows Chrome, 42.3% with 21.2% success from Singapore, and 23.6% with 8.65% success from the United Kingdom (arXiv:2406.12373, 2024).

The experiment was about planning-model behaviour, not security, and the causal story does not transfer. The shape does. Geography correlates with outcome through a chain of factors that have nothing to do with who is on the other end, and a security control built out of geography inherits every one of them.

The Port Scan You Fear Is Not the Attack That Arrives​

Does an open SSH port matter if you use key-only authentication? Yes — but not for the reason the hardening guides imply. The scanning is real and constant; the exploitation is the part that keeps failing. Across 20 and 23 test runs, the frontier models in that simulated evaluation found services and found ports. They could not convert either into access, and post-exploit attempts to maintain presence were "entirely unsuccessful" as well (Dubey et al., 2024).

Meanwhile OWASP's Top 10 for Agentic Applications — published 2025-12-09 by John Sotiropoulos with Keren Katz and Ron F. Del Rosario, drawing on more than 100 industry experts — lists ten risks, and not one of them is an exposed TCP port (OWASP Gen AI Security Project, 2025).

ASI01 is goal hijack, where hidden prompts turned copilots into "silent exfiltration engines." ASI03 is identity and privilege abuse, where leaked credentials enabled operation beyond intended scope. ASI09 is human-agent trust exploitation, where "confident, polished explanations misled human operators into approving harmful actions." Ten categories, and the network perimeter is not among them (OWASP Gen AI Security Project, 2025).

The stakes run backwards from intuition. Spend your hardening budget closing the port and stopping there, and you have defended against the attack class with the highest effort-to-failure ratio while leaving the agent's tool-calling identity unconstrained. Skip it entirely and you have left a door that probably holds — until someone with better tooling than Llama 3 arrives, which is a certainty on a long enough timeline. Closing the port is cheap. Closing it properly takes an ordering discipline most guides omit, and that is the only reason the topic deserves an article.

How Do You SSH Through Cloudflare Tunnel Without an Open Port?​

The mechanism is an outbound-only connection with an identity check in front of it. With Cloudflare Tunnel, "a lightweight daemon in your infrastructure (cloudflared) creates outbound-only connections to Cloudflare's global network," and it can connect SSH servers (Cloudflare Tunnel overview). The documentation's own summary of the consequence is the whole argument: "You can then configure your firewall to allow only these outbound connections and block all inbound traffic, effectively blocking access to your origin from anything other than Cloudflare" (Cloudflare Tunnel overview).

[ admin laptop ] --HTTPS 443--> [ Cloudflare edge: Access policy ]
|
v
[ cloudflared on the VPS, outbound only ]
|
v
sshd on 127.0.0.1:22

A locally-managed tunnel expresses its routes as ingress rules in a config file on the host, and that config must always include a catch-all rule. Pointing the SSH hostname at the loopback address means sshd never needs a public listener rule at all:

tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: ssh.example.com
service: ssh://localhost:22
- service: http_status:404

That last line is not decoration. A config without a catch-all is rejected by the daemon, and http_status:404 is the documented default that makes an unmatched hostname dead rather than undefined (tunnel configuration file). On the host, cloudflared installs as a system service, a step the docs note "typically requires elevated privileges" (run cloudflared as a Linux service).

The laptop side is one stanza. The client runs the same daemon in access mode, and the traffic it emits is ordinary HTTPS on 443, so it does not compete with a corporate VPN client for routes or DNS (connect to SSH with cloudflared):

Host ssh.example.com
ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
User agentadmin

Note what ProxyCommand does here. It replaces the TCP connection with a pipe to a local process, which is why there is no inbound port to scan and no route to hijack. The daemon still requires a key (sshd_config). You end up with two independent locks — identity at the edge, key at the host — and neither alone is sufficient. That is the point.

Can Tailscale Run Alongside a Corporate VPN? Not on a Managed Laptop​

This is not a flaw in either product, and framing it as one leads people to the wrong fix. Tailscale addresses come "from the shared address space defined in RFC6598, known as Carrier-Grade NAT (CGNAT)," the 100.64.0.0/10 subnet (Tailscale IP addresses). Tailscale's own FAQ says some VPNs cannot run alongside it "without a workaround … Usually, this is due to aggressive firewall rules, device limitations, or IP address conflicts," because "Most VPNs set aggressive firewall rules to ensure all network traffic goes through them" (Tailscale FAQ).

The documented workaround is a split-tunnel exclusion for 100.64.0.0/10 and fd7a:115c:a1e0::/48 (Tailscale FAQ). On a personal laptop that is a five-minute change. On a managed corporate client, the routing table and the DNS settings belong to somebody else, and the operator cannot add either exclusion. The failure mode is nastier than "it doesn't connect": routes flap, DNS resolves to the wrong resolver, and intermittent failures get blamed on the server that is working fine.

The stability question is the one worth measuring, and the literature has numbers even from another domain. On a multi-server edge testbed with a strict service-level objective of 0.5 s, a mean-only selection policy missed its deadline 39% of the time and switched servers 46% of the time; adding explicit risk evaluation plus hysteresis cut deadline misses to 34% and switching frequency to 5.5% — roughly an 88% reduction — while holding average latency near 0.45 s (Liyanage et al., 2026). A control that oscillates is a control operators stop trusting and start routing around.

The scalability literature treats topology as a measurement dimension rather than a verdict, which is the honest version of this argument at operator scale. A survey of agent communication protocols defines link scalability as protocol behaviour as link density increases, and proposes a fully connected mesh of 1,000 agents against a sparse network as the test to run (arXiv:2504.16736, 2025). That is a dimension to measure, not evidence that meshes win. One operator and one VPS is not a mesh, and paying mesh routing complexity for a problem a single outbound tunnel solved is a choice you should be able to defend with a number, not an assumption.

A Non-Standard Port Buys Obscurity, Not a Smaller Door​

Before settling on a tunnel, two cheaper-looking options deserve an explicit rejection. Both appear in every "harden your VPS" post, and neither survives the ordering problem below.

The first is moving sshd to a non-standard port. The directive is one line in the daemon's configuration (sshd_config), and the effect is real but narrow: it reduces automated scanning and brute-force noise aimed at the default port without changing the size of the attack surface. Your listener is still inbound, still reachable by anyone willing to sweep the port range. Obscurity buys quieter logs, not a smaller door, and it is not a substitute for key-based authentication and firewall rules.

The second is a bastion or jump host: one hardened machine reachable from outside, with everything else reachable only through it. That is a real architectural improvement — many exposed daemons collapse into one — and it is the right answer when you manage several hosts. At a scale of one operator and one VPS it is not. The client-side plumbing is a jump directive in the SSH client config (ssh_config); the operational cost is a second box to patch, monitor, and break-glass into.

Port knocking and single-packet authorization schemes split the difference and inherit the worst of both. They keep the listener and hide it behind a secret sequence, adding a client-side dependency and a host-side state machine that fails closed in exactly the way the allowlist does. What all three share is an inbound listener. The tunnel removes the listener, which is why it wins on the merits rather than on fashion.

Close Port 22 Only After You Have Proven the New Path​

Here is the part that overturns the intuition most people bring to this migration. Moving to a tunnel is not monotonically safer. For a window of minutes it can be strictly less safe than the allowlist you are replacing, and the reason is a default.

Access is deny-by-default, but only for hostnames it has an application for: "All Access applications are deny by default — a user must match an Allow policy before they are granted access" (access policies). Start the tunnel first and create the Access application second, and there is a period where the hostname resolves, the tunnel is live, and nothing stands in front of it. Anyone who can run the client reaches sshd.

That is not a smaller version of the exposure you had. It is a larger one. The firewall rule you were relying on is still there, but it is no longer the only path in — the tunnel bypasses it silently. Sequence the work the other way: application and policy first, tunnel second, firewall last. Then prove the new path by using it to remove the old one.

Deleting the port-22 rule over the tunnel is the test. If the tunnel were broken you would find out immediately, with the old rule still in your shell history and your provider's console one browser tab away. Close the port first and test afterwards, and the test failure has no fallback. You are in rescue mode, having learned nothing except that you sequenced badly.

There is a second default that bites the same way. An application configured to accept all identity providers accepts the broken ones too. If the account holds an old identity-provider integration whose OAuth client was deleted, instant authentication redirects into a dead login. Pin the application to the provider you actually mean — a one-time PIN sent to the user's email needs no third-party identity provider at all (one-time PIN login).

And know what failure looks like before you need to recognise it. Cloudflare error 1033 means the hostname's tunnel has no active connection (Cloudflare error 1033). A 1033 on the SSH hostname is the signal to reach for break-glass, not to reopen the port. The difference between those two responses is decided by whether you wrote the runbook in advance.

A Second Lock Beats a Better Lock — and It Should Never Touch Disk​

A policy in Access has actions — Allow, Block, Bypass, Service Auth — and rule types — Include, Require, Exclude — each with selectors and values (access policies). The smallest useful policy is an Allow that Includes one email address. Resist enlarging it. Every additional include is a second person who can reach a machine holding credentials.

The industry's other agent protocols agree with the tunnel vendors here. The A2A enterprise guidance insists all production communication run over HTTPS and recommends TLS 1.2 or higher — the specification itself says TLS 1.3+. It states the principle plainly: "No Identity in Payload: A2A protocol payloads, such as JSON-RPC messages, don't carry user or client identity information directly. Identity is established at the transport/HTTP layer" (A2A: enterprise-ready). Same reasoning as the edge policy: identity belongs to infrastructure that can enforce it, not to something the agent can assert about itself.

The layered design is not unique to infrastructure either. In a Web3 execution system built around a three-tier edge-local-cloud split, safety is explicitly "bound to the edge execution primitive" while sensitive intent stays sovereign, and the system reaches a 93.7% multi-turn success rate on protocol-constrained tasks (Huang et al., 2026). Edge enforcement plus host-level enforcement, each independent, each able to fail alone. That is the shape you want on a VPS: identity at the edge, key at the host, neither able to substitute for the other.

Now the part the tunnel does not fix. The first of ten confidentiality case studies in that agent-security evaluation — "Unsanitized Snapshot → Credential Leak" — needed no cleverness at all. The credentials were where the snapshot could read them, and the attack path succeeded 3/3 (arXiv:2606.23075, 2026). A tunnel changes who can reach your host. It changes nothing about what a compromised agent finds once it is running there.

The cheap mitigation is to make secrets exist only in memory. A tmpfs "keeps all of its files in virtual memory … no files will be created on your hard drive," and unmounting it loses everything (tmpfs, Linux kernel documentation). A provider that images your disk — for backups, for migration, for a support investigation — never sees them. The price is real and you should write it down: a reboot wipes them, so re-provisioning has to be a script, not a memory.

Then constrain who can read what. A dedicated service user with no shell for each long-running component, with credentials delivered through EnvironmentFile= — read by the service manager before privileges are dropped, so the process receives the values without being able to open the file (systemd.exec(5)). Give each unit only the names it reads. The component that runs unattended with broad tool access must never receive the token that can publish or deploy. That split is the difference between an incident and a supply-chain incident.

Logging at the Wrong Granularity Produces Confident, Useless Alerts​

Two failure modes sit on either side of good alerting, and both have been measured.

The first is false positives. A queue-aware streaming intrusion detector for constrained gateways, scoring encrypted-traffic metadata with a two-state dynamic unit, reached 0.952 incident recall against 0.857 for the best baseline at an achieved 0.1% false-positive operating point — at a scoring cost of roughly 2.09 µs per flow-window on CPU (Bilal, Tariq & Ahmed, 2026). The detection itself was nearly free. What made it usable was a K-of-M persistence rule: no single window triggers mitigation, only a sustained pattern does (Bilal, Tariq & Ahmed, 2026). Copy that discipline to your own alerts. One failed authentication is noise; a burst across a window is a signal.

The second is granularity, and it is subtler. In an AML evaluation on the Elliptic++ dataset — 203,769 transactions and 822,942 address occurrences — scoring at transaction level versus actor level produced review queues that barely agreed: mean Jaccard of 0.374 under temporal evaluation, 0.087 under static pooled evaluation, and 0.051 for an enriched address model given all 237 features. At a one-percent review budget, that enriched model put 4.3% illicit cases per 100 reviews against 30.2% for the transaction-projected queue, and a fixed hybrid of the two underperformed the best single-level queue by 5.05 percentage points (Malik, 2026).

Read that as a warning about your own review surface. Alert on the wrong unit — per-request instead of per-session, per-process instead of per-identity — and you can build a queue that looks rigorous and contains almost nothing.

More features made the enriched model worse, and "hybrid" — which sounds safer — measured worse too. Pick one granularity. Measure what it catches. Only then complicate it (Malik, 2026).

And remember who reads the output. OWASP's ASI09 exists because "confident, polished explanations misled human operators into approving harmful actions" (OWASP Gen AI Security Project, 2025). Your agent's own summary of what it did is exactly that kind of explanation. Log the transport facts — who authenticated, from where, when — and treat the agent's account of itself as untrusted narrative.

Break-Glass Is a Script You Have Rehearsed, Not a Feeling​

The automation case for scripted recovery is stronger than instinct suggests. In one large training operation, of 466 interruptions, 47 were planned and 419 were unexpected; about 78% of the unexpected ones traced to confirmed or suspected hardware problems, GPU issues alone accounted for 58.7% of them, and significant manual intervention was required only three times, with everything else handled by automation (Dubey et al., 2024). Scale changes the numbers, not the principle: the recovery path that works is the one that runs without a human inventing it under pressure.

On a single VPS, that path is provider rescue mode, which boots a temporary system from which the real disk can be mounted and its firewall rules edited (OVHcloud rescue mode; every major provider has an equivalent). Treat it as the last resort it is. A rescue boot wipes tmpfs, so budget for re-provisioning secrets and unlocking any encrypted volume as part of the same runbook. The secrets design and the break-glass design are the same document.

A web console with a password login is only a fallback if the password has been tried recently. Browser consoles mangle keyboard layouts, so a correct password fails for reasons that look like a wrong password, and you will not discover that during an incident. The first connection to the new SSH hostname also needs a deliberate known-hosts entry: the host key is the same machine's, but accepting it blind defeats the verification the tunnel exists to preserve.

Frequently Asked Questions​

Q: How do I close port 22 on my VPS? A: Stop or disable the SSH service on the default port and rebind it, or block that port entirely with your firewall rules so only your new access method is reachable (sshd_config).

Q: Can I secure a VPS without Tailscale? A: Yes. An outbound-only tunnel with an identity check in front, a bastion host, and SSH key-only authentication are common alternatives that do not require a mesh VPN (Cloudflare Tunnel overview).

Q: Does changing the SSH port actually improve security? A: It reduces automated scanning and brute-force noise against the default port, but it is not a substitute for key-based authentication and firewall rules (sshd_config).

Q: How do I check if port 22 is still open? A: Use a port scanner or connection test from an external host to confirm it is unreachable, then re-verify after any firewall or SSH config change (ufw).

Q: Will closing port 22 break my AI agent's access? A: Only if the agent connects over that port. Rebind or tunnel that access first, then close it and confirm the agent still connects (connect to SSH with cloudflared).

Practical Takeaways​

  1. Create the Access application and its Allow policy for the SSH hostname before the tunnel starts running. This ordering is the single highest-value rule in the article (access policies).
  2. Point the tunnel ingress at loopback port 22 with a catch-all 404 behind it, and run the daemon as a system service (tunnel configuration file).
  3. Put one SSH config stanza on the laptop using the tunnel client as its proxy command, and pin the host key deliberately (connect to SSH with cloudflared).
  4. Remove the old port-22 allow rule over the new path, so the removal is the test, and leave the firewall default-deny on inbound (ufw).
  5. Split secrets per component, keep them in memory, and give each service an unprivileged user with no shell (tmpfs, Linux kernel documentation).
  6. Pin the Access application to one identity provider you control; one-time PIN needs no third party (one-time PIN login).
  7. Treat a 1033 on the SSH hostname as a break-glass trigger, not a reason to reopen the port (Cloudflare error 1033).
  8. Write and rehearse the rescue-mode runbook, including the re-provisioning step, before you need it (OVHcloud rescue mode).

The Metric That Matters Is Time to Recover​

The premise of closing the port without a mesh VPN, without an IP allowlist, is that your security model should not contain a dependency you do not control. Not the ISP's address pool. Not a route table on a laptop somebody else manages. Not an authentication provider you configured once and forgot.

What replaces those dependencies is a short list of ordering rules — application before tunnel, tunnel before firewall removal, secrets before deployment — and every one of them is a rule you can get wrong and will not notice until the day it matters. That asymmetry is the honest argument for the whole architecture. It is also the reason the checklist above is worth more than the diagram.

The scan you were worried about was never the thing that got in. Being locked out of your own agent at the exact moment it needed you was. The fix costs an afternoon and a tunnel config file.

Three of the works cited here — the agent threat-amplification study, the browser-agent evaluation, and the agent-protocol scalability survey — are credited by arXiv identifier and year because the source material for this article supplied no author list for them.