DEV Community

Cover image for How I Hardened My VPS in One Afternoon: SSH, Cloudflare, and Tailscale
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

How I Hardened My VPS in One Afternoon: SSH, Cloudflare, and Tailscale

Originally published at hafiz.dev


A tweet from @levelsio went viral last week. The advice was simple: lock down your VPS before someone else does.

I checked my own server settings immediately. PermitRootLogin yes. PasswordAuthentication defaulting to enabled. Port 22 open to the entire internet. No Cloudflare proxy. Nothing.

That's fully exposed root SSH access on a production server running multiple live projects. Not great.

So I fixed all of it. SSH hardening, Cloudflare DNS migration, Tailscale installed, port 22 locked. Here's exactly what I did, in the order I did it, with the real commands and the mistakes I made along the way.

Why This Stack Specifically

There are a hundred ways to "secure" a server. Most guides tell you to do one thing. The levelsio setup is three independent layers working together:

  • SSH: key-based auth only, no passwords, no brute-force possible
  • Cloudflare: your real server IP stays hidden, all web traffic proxied
  • Tailscale: port 22 becomes invisible to the public internet

Each layer is useful on its own. Together they make your attack surface genuinely small. An attacker would need to break all three simultaneously to get anywhere.

Here's what the setup looks like once it's done:

flowchart LR
    A[Internet / Bots] -->|HTTPS only| B[Cloudflare]
    B -->|Proxied| C[Your VPS]
    D[Your Mac] -->|SSH via Tailscale| C
    A -.->|Port 22 invisible| C
    A -.->|Real IP hidden| B
Enter fullscreen mode Exit fullscreen mode

The dashed lines are failed attacks. That's the goal.

Layer 1: SSH Hardening

Do this first. It's the most urgent fix and takes five minutes.

SSH into your server and check what you're working with:

grep -E "PermitRootLogin|PasswordAuthentication" /etc/ssh/sshd_config
Enter fullscreen mode Exit fullscreen mode

If you see PermitRootLogin yes or nothing at all for PasswordAuthentication (which means it defaults to yes), you need to change both. Open the config file:

nano /etc/ssh/sshd_config
Enter fullscreen mode Exit fullscreen mode

Change these two lines:

PermitRootLogin prohibit-password
PasswordAuthentication no
Enter fullscreen mode Exit fullscreen mode

Save and restart SSH:

systemctl restart sshd
Enter fullscreen mode Exit fullscreen mode

prohibit-password means root can still log in, but only with an SSH key. Since you're already using SSH keys, nothing changes for you day-to-day. What changes is that password brute-force attacks are now completely useless. Bots can hammer the port all they want.

One thing before you make this change: confirm you know where your private key file is (~/.ssh/id_rsa or ~/.ssh/id_ed25519 on Mac). And make sure your SSH key passphrase is strong. If it isn't, a password generator can help you create something that isn't guessable. If you ever do get locked out, DigitalOcean and Hetzner both have browser-based console access in their dashboards. That's your emergency backdoor and it bypasses SSH entirely.

Test your key auth works, apply the changes, test again. Don't just assume.

Layer 2: Put Cloudflare In Front of Your Server

If your domain's A record points directly at your server IP, that IP is public. Anyone can find it, target it, and bypass whatever you set up in Cloudflare later. The proxy only works if you actually use it.

Cloudflare free tier gives you:

  • Your real server IP hidden behind Cloudflare's network
  • DDoS protection at the application layer
  • Free SSL certificate management (no more certbot renewals)
  • CDN caching for static assets
  • Analytics at the infrastructure level

The migration causes zero downtime. Here's how it works.

Step 1: Create your Cloudflare account and add your domain

Go to cloudflare.com, sign up, then add your domain. Choose "Import DNS records automatically". Cloudflare scans your existing DNS and pulls everything in.

Step 2: Review the imported records carefully

Cloudflare's auto-import is good but not perfect. In my case it missed two records that were in Namecheap but didn't show up in the scan: a subdomain A record and a second DKIM record for ConvertKit (cka2._domainkey). Always compare the Cloudflare list against your registrar's Advanced DNS settings manually before switching.

One rule that matters: DKIM, DMARC, and SPF records must stay as DNS only (grey cloud), not proxied (orange cloud). Proxying email-related records breaks email delivery. Your A records for the main domain and www should be proxied. Everything email-related should not be.

Step 3: Add missing records manually

If you find gaps, use "Add record" to fill them in before proceeding. Getting this right now saves you debugging email deliverability issues later.

Step 4: Change your nameservers

Cloudflare gives you two nameservers (something like anton.ns.cloudflare.com and megan.ns.cloudflare.com). Go to your registrar, find the nameservers section, switch from their default to Custom DNS, and enter the two Cloudflare nameservers.

DNS propagation takes anywhere from 5 minutes to a few hours. In my case it took under two minutes. Cloudflare emails you when the domain goes active.

On the AI crawler setting: during setup Cloudflare asks if you want to block AI training bots. If you run a content site and want visibility in AI-generated answers, choose "Do not block". If you have an llms.txt file or care about AI referencing your content, blocking crawlers contradicts that completely.

Once Cloudflare is active, your server IP is no longer the first thing that resolves for your domain. Attackers hitting your domain hit Cloudflare first. That's the point.

Layer 3: Tailscale

This is the part most people skip. It sounds complicated. It's not. It took me 15 minutes start to finish.

Tailscale creates a private mesh network between your devices. Once installed on your laptop and your VPS, both get private Tailscale IP addresses in the 100.x.x.x range. These IPs only exist within your Tailscale network. Nobody outside can see or reach them.

The goal is to lock port 22 so it only accepts connections from the Tailscale network. The public internet can't even see the port exists.

Install Tailscale on your Mac

Download from the Mac App Store, or via Homebrew:

brew install tailscale
Enter fullscreen mode Exit fullscreen mode

Sign in with Google or GitHub. Your Mac gets added to your Tailscale network and gets a private IP like 100.79.x.x.

Install Tailscale on your VPS

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
Enter fullscreen mode Exit fullscreen mode

The second command outputs a URL like https://login.tailscale.com/a/xxxxxx. Open it in your browser, authenticate with the same account you used on your Mac, and the VPS joins your network.

You might see a warning during installation about the running kernel version not matching the expected version. This is Ubuntu telling you there's a pending kernel update that requires a reboot to apply. It doesn't affect Tailscale at all. Schedule a sudo reboot when it's convenient and the server will come back up with everything running automatically.

Check the Tailscale dashboard

Go to the Machines tab in your Tailscale account. You should see two devices, both showing "Connected", each with a 100.x.x.x IP address.

Test SSH through Tailscale before locking anything

This step is not optional. Open a new terminal and SSH using the VPS Tailscale IP:

ssh root@100.x.x.x   # use your VPS Tailscale IP
Enter fullscreen mode Exit fullscreen mode

If you get in, you're ready to lock port 22. If you can't connect, stop and figure out why before going any further.

Lock port 22 to Tailscale only

Run these three commands on your server:

ufw allow in on tailscale0 to any port 22
ufw deny 22
ufw reload
Enter fullscreen mode Exit fullscreen mode

Then open a second terminal and SSH via the Tailscale IP again. If it connects, you're done. Port 22 is now invisible to the public internet.

Update your SSH config on your Mac

So you don't have to remember the Tailscale IP:

# ~/.ssh/config
Host your-server
    HostName 100.x.x.x    # your VPS Tailscale IP
    User root
    IdentityFile ~/.ssh/id_rsa
Enter fullscreen mode Exit fullscreen mode

From this point on, ssh your-server works exactly as before. Tailscale runs as a background service on your Mac and starts automatically on login. You'll never notice it's there.

What About Daily Workflow?

Nothing changes. You still SSH the same way, just to the Tailscale IP instead of the public one. Your Laravel app deploys the same way. Your deploy scripts, Nginx configs, cron jobs, Supervisor processes all keep running as normal. If you're running Docker containers on the same server, those are untouched too. Tailscale only affects how you get into the machine, not what runs inside it.

The only difference you'll notice is that if Tailscale somehow isn't running on your Mac, SSH to the Tailscale IP will time out. Your fallbacks in that case:

  1. Start Tailscale on your Mac (it's a menu bar app, one click to reconnect)
  2. Use DigitalOcean's browser console (always available, no SSH needed)
  3. Install Tailscale on your iPhone as a backup device

Tailscale's reliability has been solid in my experience. It starts automatically on your Mac at login and reconnects if your internet drops. But knowing your escape routes before you need them is basic practice. I keep the DO console bookmarked. It's saved me once already.

One thing worth doing after you lock port 22: run ufw status and check what's actually open. Your output should look something like this:

Status: active

To                         Action      From
--                         ------      ----
Nginx Full                 ALLOW       Anywhere
22 on tailscale0           ALLOW       Anywhere
22                         DENY        Anywhere
Enter fullscreen mode Exit fullscreen mode

If port 22 shows DENY and you can still SSH in via the Tailscale IP, the setup is working exactly as intended.

The Honest Trade-offs

This setup isn't magic. Here's what you're actually signing up for:

Cloudflare free tier proxies HTTP/HTTPS only. Non-standard ports, custom TCP protocols, game servers: none of these get proxied on the free plan. For a standard web app, you won't hit this limit. But if you're running something unusual, check the Cloudflare Spectrum pricing before assuming everything's covered.

Tailscale adds a dependency for SSH access. Any device you SSH from needs Tailscale installed. For a solo developer with one laptop, this is fine. If you ever need to SSH from a machine you don't control, you'd need to install Tailscale there first, or fall back to the DO console.

Cloudflare adds a tiny latency overhead for cache misses. Requests that hit your origin server (dynamic Laravel responses) pass through Cloudflare's network. The overhead is negligible in practice, we're talking single-digit milliseconds. For static assets, Cloudflare is actually faster because files serve from a nearby edge node.

For a solo developer running production projects on a $5-20/month VPS, these trade-offs are worth it. Both Tailscale and Cloudflare are free for this use case.

Security Doesn't Stop at the Server

Once you've hardened your VPS, the next thing worth auditing is what's running on it. If you're deploying Laravel apps, your Composer dependencies are another real attack surface. Fake packages mimicking legitimate ones have been found in the wild. This walkthrough on auditing your Composer dependencies covers how to check what's actually installed and what to look for.

Infrastructure security and application security are different layers. You need both.

FAQ

Do I need Cloudflare if I'm not worried about DDoS attacks?

The DDoS protection is a bonus, not the main reason. The real value is hiding your server IP. If attackers can't find your server's IP address, they can't target it directly. The free tier is worth setting up for any public-facing production server.

Can I use Tailscale on Windows instead of Mac?

Yes. Tailscale has native clients for Windows, macOS, Linux, iOS, and Android. The VPS setup is identical regardless of which device you connect from.

What if Tailscale goes down and I need to SSH in?

Use your VPS provider's browser-based console. DigitalOcean calls it the Droplet Console, Hetzner calls it the Console button. It gives you terminal access directly through the browser without needing SSH at all. Bookmark it before you need it.

Does this work on Hetzner, AWS, or other VPS providers?

Yes. The SSH hardening and Tailscale steps are identical on any Ubuntu/Debian server. For Cloudflare, you just need control of your domain's DNS. The server provider doesn't matter.

What's the order I should do this if my server is already live?

SSH hardening first (today, takes five minutes). Cloudflare second (takes 20 minutes, zero downtime). Tailscale third (takes 15 minutes, test before locking). Each step is independent, so you can space them out over a few days if needed. But the SSH hardening is the one to do immediately.

The Summary

The whole setup took me one afternoon. SSH hardening took five minutes. Cloudflare took twenty. Tailscale took fifteen.

Your server is running right now with bots probing it constantly. Most of the time nothing happens. But "most of the time" isn't a security strategy when you're running client projects on that machine.

Do the SSH hardening today. Add Cloudflare this week. Set up Tailscale when you have 30 minutes. Each layer is independent and each one meaningfully reduces your exposure.

If you're building something and want to talk through the setup for your specific stack, get in touch.

Top comments (0)