Zero Open Ports: Secure Your VPS in 15 Minutes
Your VPS has ports exposed to the internet right now. Here's how to close every single port and still access everything using Cloudflare Tunnel.
Your VPS has ports exposed to the internet right now. SSH on 22, maybe a database on 5432, internal tools on random ports. Millions of bots are scanning 24/7 looking for exactly this. Here's how to close every single port and still access everything using Cloudflare Tunnel.
Why Close All Ports?
Most developers expose ports directly to the internet: SSH on 22, databases on 5432, analytics dashboards on 8080. Even with strong passwords, you're giving bots a door to knock on. DNS doesn't help - anyone can dig your domain and find your real IP. Even Cloudflare's proxy only covers HTTP ports.
| Benefit | Description |
|---|---|
| Zero Attack Surface | No open ports means nothing for bots to scan - your server becomes invisible |
| Identity-Based Access | Every connection authenticated - SSH, databases, internal tools, everything |
| No VPN Required | Works from anywhere, any network - just authenticate and you're in |
Before You Start
This guide uses Cloudflare DNS + Cloudflare One (their Zero Trust platform, free tier available).
If your domain isn't on Cloudflare yet, you can add it for free - takes about 5 minutes. Or just read along to see if this approach fits your setup.
Step 1: Create the Tunnel (Takes 3 Minutes)
In Cloudflare One Dashboard:
- Go to Networks → Connectors → Cloudflare Tunnels
- Click Create a tunnel
- Choose Cloudflared as the connector type → Next
- Name it something like
vps-tunnel→ Save tunnel - Select your OS environment, then copy the install command - it looks like this:
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
sudo cloudflared service install eyJhIjoiYWJjZGVm...your-token-hereRun it on your VPS. Once the command finishes, your connector will appear in Cloudflare One.
| What just happened | Why it matters |
|---|---|
| Outbound-only connection | No inbound ports needed |
| Runs as a systemd service | Survives reboots automatically |
| Encrypted by default | Traffic is secured end-to-end |
Step 2: Add SSH as a Private Application
Still in the Cloudflare Dashboard:
- Go to your tunnel → Public Hostnames → Add a public hostname
- Configure it:
| Field | Value |
|---|---|
| Subdomain | ssh (or whatever you want) |
| Domain | yourdomain.com |
| Type | SSH |
| URL | localhost:22 |
- Hit Save. Cloudflare creates the DNS record automatically.
Now lock it down:
- Go to Access → Applications → Add an application
- Select Self-hosted
- Set application domain:
ssh.yourdomain.com - Create a policy - example:
- Policy name: SSH Access
- Action: Allow
- Include: Emails ending in @yourdomain.com
Or use one-time PIN, GitHub SSO, Google Workspace - whatever fits your setup.
Step 3: Connect From Your Machine
Install cloudflared locally:
# macOS
brew install cloudflared
# Linux
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
# Windows
winget install Cloudflare.cloudflaredAdd this to your ~/.ssh/config:
Host vps
HostName ssh.yourdomain.com
User your-username
ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
# Run 'which cloudflared' to find your path
# macOS Homebrew: /opt/homebrew/bin/cloudflaredNow connect:
ssh vpsFirst time: browser opens for auth. After that, tokens are cached. You're in.
Step 4: Close All Ports
Don't lock yourself out!
Open a new terminal and test ssh vps first. Keep your current SSH
session open as a backup until the tunnel is confirmed working.
Once you've confirmed all services work through the tunnel:
# Close ALL ports with UFW (recommended)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
# Or bind services to localhost only
# SSH: Edit /etc/ssh/sshd_config → ListenAddress 127.0.0.1
# Postgres: Edit postgresql.conf → listen_addresses = 'localhost'
# Any app: Bind to 127.0.0.1 or 0.0.0.0 → localhost onlyRestart services if you changed their configs:
sudo systemctl restart sshd
sudo systemctl restart postgresql # if applicableDone. All ports closed. Everything still works through the tunnel.
What You Get
- Zero open ports - your server becomes invisible to port scanners and bot networks
- All services protected - SSH, databases, internal dashboards, analytics - everything through one tunnel
- Identity-based access - every connection authenticated, not just SSH keys
- Audit logs - every connection logged in Cloudflare dashboard with user identity
- No VPN required - works from anywhere, any network, just authenticate and connect
- Free tier - Cloudflare Tunnel is free for unlimited bandwidth and connections
Emergency access
If the tunnel goes down, you'll need console/VNC access from your VPS provider (Hetzner, DigitalOcean, Linode, etc. all offer this). Keep those credentials safe.
The Security Evolution
Most developers go through these stages. Each step seems reasonable, but only the last one actually protects you.
❌ Scenario 1: Direct IP Mapping (WORST CASE)
┌─────────────────┐ ┌─────────────────────────┐
│ Developer │───SSH:22──────────>│ VPS (24.102.42.42) │
│ Laptop │ │ │
└─────────────────┘ │ 0.0.0.0:22 SSH │
│ 0.0.0.0:8080 Analytics │
┌─────────────────┐ │ localhost:5432 DB │
│ Cloud (Vercel) │───:8080───────────>│ │
│ │ └─────────────────────────┘
└─────────────────┘ ↑ ↑ ↑ ↑ ↑
Millions of bots scanning
24/7 for vulnerabilitiesServices bound to 0.0.0.0 = visible to the entire internet. Your IP is in env vars, configs, and DNS records. Bots find you in seconds.
⚠️ Scenario 2: DNS Mapping (STILL EXPOSED)
"I'm using a domain now!" - Cool, but dig analytics.domain.com still shows your real IP. DNS is not security. Same exposure, extra steps.
⚠️ Scenario 3: Cloudflare Proxy (PARTIAL FIX)
Cloudflare proxy hides your IP for HTTP traffic - nice! But SSH (port 22) can't be proxied. Port still open, bots still knocking. You need something else for SSH.
✅ Scenario 4: Cloudflare Tunnel (ZERO OPEN PORTS)
┌─────────────────┐ ┌───────────────┐ ┌─────────────────────────┐
│ Developer │ ───> │ │ <=== │ VPS (24.102.42.42) │
│ (cloudflared) │ │ Cloudflare │ │ cloudflared tunnel │
└─────────────────┘ │ Edge │ │ (OUTBOUND connection) │
│ │ │ │
┌─────────────────┐ │ │ <=== │ localhost:22 SSH │
│ Cloud (Vercel) │ ───> │ │ │ localhost:8080 Analytics│
│ │ │ │ │ localhost:5432 DB │
└─────────────────┘ └───────────────┘ └─────────────────────────┘
===> = Outbound tunnel (VPS connects TO Cloudflare, not the other way)
No inbound ports needed. Bots find NOTHING.All services bound to localhost. Tunnel makes outbound connection to Cloudflare. No inbound ports. Bots scan your IP, find nothing, move on. You win.
Add More Services (Same Tunnel)
One tunnel handles everything. Add more hostnames for each service:
# /etc/cloudflared/config.yml (if using config-based setup)
ingress:
- hostname: ssh.yourdomain.com
service: ssh://localhost:22
- hostname: analytics.yourdomain.com
service: http://localhost:8080
- hostname: grafana.yourdomain.com
service: http://localhost:3000
# Database access via tunnel (no public hostname needed)
# Connect via: cloudflared access tcp --hostname db.yourdomain.com --url localhost:5432
- service: http_status:404Each service gets its own Access policy. Your analytics dashboard can require GitHub SSO, your SSH can require email verification, your database can be internal-only. Granular control over who gets access to what.
Don't forget: Update your environment variables wherever your app is deployed (Vercel, Netlify, Railway, etc.) to use the new tunnel hostnames instead of direct IPs.
Learn More
💡 15 minutes of setup. Zero open ports. All your services still accessible. Bots move on to easier targets.
