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.
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.
No open ports means nothing for bots to scan - your server becomes invisible
Every connection authenticated - SSH, databases, internal tools, everything
Works from anywhere, any network - just authenticate and you're in
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.
In Cloudflare One Dashboard:
vps-tunnel → Save tunnelcurl -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 |
Still in the Cloudflare Dashboard:
| Field | Value |
|---|---|
| Subdomain | ssh (or whatever you want) |
| Domain | yourdomain.com |
| Type | SSH |
| URL | localhost:22 |
Now lock it down:
ssh.yourdomain.comOr use one-time PIN, GitHub SSO, Google Workspace - whatever fits your setup.
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 vps First time: browser opens for auth. After that, tokens are cached. You're in.
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\nsudo systemctl restart postgresql # if applicableDone. All ports closed. Everything still works through the tunnel.
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.
Most developers go through these stages. Each step seems reasonable, but only the last one actually protects you.
┌─────────────────┐ ┌─────────────────────────┐
│ 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.
┌─────────────────┐ ┌──────────┐ ┌─────────────────────────┐
│ Developer │────>│ DNS │────>│ VPS (24.102.42.42) │
│ Laptop │ │ Provider │ │ │
└─────────────────┘ └──────────┘ │ 0.0.0.0:22 SSH │
│ │ 0.0.0.0:8080 Analytics │
┌─────────────────┐ │ │ localhost:5432 DB │
│ Cloud (Vercel) │──────────┘ └─────────────────────────┘
│ env: analytics │ ↑ ↑ ↑ ↑ ↑
│ .domain.com │ dig analytics.domain.com
└─────────────────┘ → reveals 24.102.42.42"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.
┌─────────────────┐ ┌─────────────────────────┐
│ Developer │─────SSH cannot be────────│ VPS (24.102.42.42) │
│ Laptop │ proxied! ───────────>│ :22 STILL EXPOSED │
└─────────────────┘ │ │
┌───────────────┐ │ 0.0.0.0:22 SSH │
┌─────────────────┐ │ Cloudflare │ │ 0.0.0.0:8080 Analytics │
│ Cloud (Vercel) │───>│ DNS + Proxy │────>│ localhost:5432 DB │
│ (IP is hidden) │ │ (HTTP only) │ └─────────────────────────┘
└─────────────────┘ └───────────────┘ ↑ ↑ ↑
Port 22 still open
Bots still scanningCloudflare 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.
┌─────────────────┐ ┌───────────────┐ ┌─────────────────────────┐
│ 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.
Inbound vs Outbound: Traditional setups require your server to accept connections (inbound). Cloudflare Tunnel flips this - your server initiates the connection (outbound) to Cloudflare's edge. No listening ports, no attack surface, no bots.
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.
💡 15 minutes of setup. Zero open ports. All your services still accessible. Bots move on to easier targets.
The AI workspace built for production. Access all models, infinite canvas for complex workflows, and tools designed for real-world projects, not just code generation. Join the waitlist to get early adopter perks.