Skip to main content

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

· 15 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, prove the new path works, then delete the old port-22 firewall rule over that new path. Nothing on the host listens for inbound connections, and no mesh VPN client has to share the laptop with a corporate one.

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 keeps port 22 shut to the world, opens it to your own address, and adds a mesh VPN when you need to get in from anywhere. Both halves tie the security of a machine that holds live API keys to something you do not control: your ISP's address pool, and a laptop routing table that a corporate VPN client already owns. What I would reach for instead is Cloudflare Tunnel with Cloudflare Access in front — an outbound-only connection plus an identity check, which takes both dependencies out of the security model.

Why an IP Allowlist Fails the Moment It Matters​

An operator runs an agent on a small VPS, with SSH locked to the home connection's public address. One morning SSH times out while the served site keeps answering — the machine is up; the port is silent. The address moved. Under carrier-grade NAT, traffic leaves through addresses the ISP shares and reassigns; Shared Address Space is 100.64.0.0/10, allocated for CGN, and the defining RFC warns that "CGN service requires non-overlapping address space on each side of the home NAT and CGN" (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 holding live API keys and executing unreviewed work. The expensive part is diagnostic, not cryptographic. From outside, a dead daemon, a changed firewall rule and a moved address are indistinguishable — one attempt, one timeout, no way to tell them apart without console access.

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. In Meta's cyber-capability evaluation of Llama 3, the 70B and 405B models "efficiently identify network services and open ports in their network reconnaissance," then failed to gain initial access across 20 and 23 test runs respectively (Dubey et al., 2024).

Meanwhile OWASP's Top 10 for Agentic Applications, published in December 2025, lists ten risks, and not one is an exposed TCP port (OWASP Gen AI Security Project, 2025). ASI01 is goal hijack; ASI03 is identity and privilege abuse (OWASP Gen AI Security Project, 2025).

So the stakes run backwards from intuition. Close the port and stop there, and you have defended against the attack class with the worst odds for the attacker while leaving the agent's credentials unconstrained.

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. The documentation states the consequence directly: "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 localhost:22

A locally-managed tunnel expresses its routes as ingress rules in a config file on the host. 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

The last line matters. Configuration files that contain ingress rules "must always include a catch-all rule that concludes the file," and the documented example uses http_status:404 so that any hostname the file does not name gets a 404 instead of reaching a service (tunnel configuration file).

On the laptop, the client side is one SSH config stanza (connect to SSH with cloudflared):

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

ProxyCommand tells the SSH client to speak through a local command instead of opening a TCP connection to the host. That command is cloudflared in access mode, which carries the session over ordinary HTTPS to Cloudflare's edge, so it does not compete with a corporate VPN client for routes. The tunnel is what removes the inbound port; the proxy command is only how your client reaches it. sshd still requires its key (sshd_config), so you end up with two independent locks: identity at the edge, key at the host.

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

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 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" (Tailscale FAQ). The documented workaround is a split-tunnel exclusion for 100.64.0.0/10 and fd7a:115c:a1e0::/48 in the other VPN (Tailscale FAQ). On a personal laptop that is a quick settings change; on a managed corporate client the configuration belongs to somebody else, and the operator may not be allowed to add the exclusion. Two clients that each want to own routing on one laptop is the conflict; the tunnel sidesteps it by not being a VPN.

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

Two cheaper-looking options deserve explicit rejection.

Moving sshd to a non-standard port — one directive in sshd_config — cuts scanning noise without changing the attack surface: the listener is still inbound and reachable by anyone sweeping the port range.

A bastion host — client-side a jump directive in ssh_config — is a real improvement across several hosts, but for one operator and one VPS it adds a second box to patch.

Port knocking hides the listener behind a secret sequence but keeps it. All three share an inbound listener. The tunnel removes the listener, which is why it wins.

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

Moving to a tunnel is not monotonically safer. For a window it can be strictly less safe than the allowlist you are replacing, and the reason is ordering.

"All Access applications are deny by default — a user must match an Allow policy before they are granted access" (publish a self-hosted application). That protection exists only for a hostname that has an Access application. Start the tunnel first and create the 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 — and the firewall rule you were relying on is no longer the only path in.

Sequence the work the other way: application and policy first, tunnel second, firewall last. Then delete the port-22 rule over the tunnel. That makes the removal itself the test: if the tunnel were broken you would find out while the old rule still works. Close the port first and test afterwards, and a failed test leaves you in rescue mode.

An application set 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 mean — a one-time PIN sent to your email needs no third-party identity provider at all.

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 Migration, Command by Command​

Every value below is a placeholder: ssh.example.com for your SSH hostname and 203.0.113.10 for the server's public address (RFC 5737 reserves that range for documentation).

1. Create the Access application first. In the Zero Trust dashboard, add a self-hosted application for ssh.example.com only, attach an Allow policy that includes one email address, and pin login to one-time PIN. Access policies are built from actions, rule types (Include, Require, Exclude), selectors and values (Access policies); one Allow with one Include is enough. Check that the hostname now redirects to a login page before going further.

2. Create the tunnel on the server (create a locally-managed tunnel):

cloudflared tunnel login                     # authorise this host for your account
cloudflared tunnel create agent-ssh # prints the tunnel UUID, writes credentials
cloudflared tunnel route dns agent-ssh ssh.example.com

3. Write the config shown above, then check it before running anything (tunnel configuration file):

cloudflared tunnel ingress validate
cloudflared tunnel ingress rule https://ssh.example.com # must print the ssh:// rule

4. Run it as a system service, so it starts at boot without a login session (run cloudflared as a service):

sudo cloudflared service install

5. Pin the host key while the old path still works. ssh-keyscan gathers the server's public keys; record them under the new hostname so the first tunnel connection is verified rather than accepted blind:

ssh-keyscan -t ed25519 203.0.113.10 | sed 's/^203.0.113.10/ssh.example.com/' >> ~/.ssh/known_hosts

6. Connect through the tunnel with the laptop stanza above. The first connection opens a browser for the one-time PIN:

ssh ssh.example.com hostname                 # answers through the tunnel

7. Over that session, close the port (ufw):

sudo ufw default deny incoming
sudo ufw delete allow from 198.51.100.7 to any port 22 proto tcp # your old allow rule
sudo ufw status verbose

8. Prove it from outside. nc in zero-I/O mode only checks for a listener:

nc -vz -w 5 203.0.113.10 22                  # should time out
ssh ssh.example.com 'echo still in' # should succeed

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

The smallest useful Access policy is an Allow that Includes one email address. Every additional include is another person who can reach a machine holding credentials.

A tunnel changes who can reach your host, not what a compromised agent finds once running there — OWASP's ASI03 covers credentials leaking beyond their intended scope (OWASP Gen AI Security Project, 2025).

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 loses everything (tmpfs, Linux kernel documentation). A provider imaging your disk never sees them. A reboot wipes them, so re-provisioning must be a script.

Constrain who can read what. Give each long-running component a dedicated service user with no shell, and deliver credentials through EnvironmentFile=, which the service manager reads before dropping privileges, so the process receives values without opening the file (systemd.exec(5)). The component with broad tool access must never receive the token that can publish or deploy.

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

Rescue mode is the last resort: it 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). A rescue boot wipes tmpfs, so the runbook must cover re-provisioning secrets and unlocking encrypted volumes — the secrets design and the break-glass design are one document.

A web console with a password login is only a fallback if the password has been tried recently. Browser consoles can mangle keyboard layouts, so a correct password fails for reasons that look like a wrong one.

Frequently Asked Questions​

How do I close port 22 on my VPS? Give SSH another way in — an outbound tunnel behind an Access policy — then delete the inbound allow rule over that path and keep the firewall default-deny on inbound (ufw).

Can I secure a VPS without Tailscale? Yes. An outbound-only tunnel with an identity check in front needs no mesh VPN and no inbound port (Cloudflare Tunnel overview).

Does changing the SSH port improve security? It reduces scanning noise, but the listener stays reachable; it is not a substitute for key-based authentication and a closed firewall (sshd_config).

How do I check that port 22 is closed? From an outside host, run nc in zero-I/O mode against port 22; it should time out, while SSH through the tunnel still works (nc).

Will closing port 22 break my AI agent's access? Only if something connects over that port. Move that access onto the tunnel first, then close the port and confirm it 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 runs (publish a self-hosted application).
  2. Point the tunnel ingress at localhost:22 with a catch-all 404 behind it, validate it, and run the daemon as a system service (tunnel configuration file).
  3. Pin the host key over the old path, then connect with cloudflared's access-mode proxy command (connect to SSH with cloudflared).
  4. Remove the port-22 allow rule over the new path, and prove from outside that the port is closed (ufw).
  5. Keep secrets in memory, split per component, delivered to unprivileged service users (tmpfs).
  6. Pin Access 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 re-provisioning, 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 or 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 identity 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 in memory before the agent starts. Every one of them is easy to get wrong without noticing until the day it matters. The scan you were worried about was never the thing that got in. Being locked out of your own agent at the moment it needed you was. The fix costs an afternoon and a tunnel config file.