Nobody asked me to do this. I want that on the record before anyone assumes there was a memo. There wasn't. I looked at our stack, did some deeply ungentlemanly math on our monthly bill, and decided that if nobody else was going to fix it, I would — quietly, competently, and with slightly too much enthusiasm for firewall rules.
I wrote about why we moved off managed platforms in the last piece. This one is the part where I actually show the work: provisioning a server and getting Coolify running on it, locked down properly, from zero.
I'm not going to undersell how much "locked down properly" ended up meaning. SSH keys and a firewall are table stakes. What actually made this server production-ready was fail2ban watching for brute-force attempts, and routing both the Coolify dashboard and SSH itself through a Cloudflare Tunnel behind Zero Trust Access — so neither one is reachable from the open internet at all, tunnel or no tunnel. That's most of this guide.
What you'll need
- A VPS provider account. I used Hetzner — good specs for the price, but any provider works the same way (DigitalOcean, Linode, Vultr).
- A terminal. I used Git Bash on Windows throughout.
- A domain, with its DNS already sitting on Cloudflare. Everything from the firewall to the tunnel in this guide assumes Cloudflare is your DNS provider, not just a CDN in front of someone else's.
- A Cloudflare account with Zero Trust enabled (the free tier covers everything here).
- About two hours, uninterrupted. This is a longer setup than "spin up a box and SSH in" — rushed server setup is where mistakes creep in, and this time we're not leaving any of them in for you to find later.
Step by step
1. Generate an SSH key before you provision anything
Do this locally first, so you can attach the public key at server-creation time instead of bolting it on after.
ssh-keygen -t ed25519 -f ~/.ssh/my_server -C "my-server"
This creates a private and public key pair. Keep the private one exactly where it landed — don't let it end up on a Desktop folder that syncs to cloud storage.
2. Create the server
Pick a region close to your actual audience, choose Ubuntu 24.04 LTS, and a size with at least 4GB RAM if you plan to run more than one or two services. Paste your SSH key's public half in at creation — most providers have a field for this.
3. Log in and create a non-root user
ssh -i ~/.ssh/my_server root@YOUR_SERVER_IP
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Confirm the new user works before doing anything else: open a second terminal and log in as deploy while your root session stays open, as a safety net.
4. Lock down SSH
Edit /etc/ssh/sshd_config and set:
PermitRootLogin prohibit-password
PasswordAuthentication no
Key-only, no root password login. Restart the service (sudo systemctl restart ssh), and verify a fresh connection works before closing your existing session.
5. Add a baseline firewall
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Also set up an edge-level firewall in your provider's dashboard with the same three rules. Two independent layers is the point — one misconfiguration shouldn't mean the server is wide open.
Do this in the same sitting as creating the server, not "once things are running." Leaving SSH open to the whole internet "for now" isn't a real problem on day one — it's the kind of thing that quietly never gets fixed later either, because there's always something more urgent by the time you remember. We're going to close port 22 to the public internet entirely by the end of this guide anyway, but don't skip the interim step assuming you'll get to the real fix soon.
6. Install and configure fail2ban
The firewall controls which ports are open. It does nothing about someone hammering port 22 with login attempts once it's open. That's fail2ban's job — it watches your auth logs and temporarily bans IPs after too many failed attempts.
sudo apt update
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Open /etc/fail2ban/jail.local and confirm the [sshd] section is enabled, with sane defaults to start:
[sshd]
enabled = true
port = 22
maxretry = 5
bantime = 1h
findtime = 10m
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
That last command should show the jail active and watching. Cheap to set up, and it's the layer that actually does something while you're asleep.
7. Install Coolify
sudo -i
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
Takes a few minutes. It installs Docker if it isn't already there, and sets up its own reverse proxy (Traefik) for automatic SSL on whatever you deploy later.
One thing to watch for right after install: if Traefik or another proxy throws odd certificate errors as soon as you try to issue SSL for anything, check Docker's IPv6 handling before you assume something's fundamentally broken. It's a known interaction — Docker's default networking can quietly interfere with SSL provisioning — and disabling IPv6 at the Docker daemon level is a five-minute fix once you know to look for it.
8. Route the Coolify dashboard and SSH through a Cloudflare Tunnel
Here's where I'll admit to a wrong turn. My first instinct for remote access was a VPN — I travel, and I wanted the dashboard and SSH reachable without punching holes in the firewall for my home IP specifically. Standard mesh-VPN tools turned out to be a bad bet: they can be restricted on certain networks, and in some countries outright, which is a bad thing to discover mid-trip. A Cloudflare Tunnel with Zero Trust Access in front of it does the same job — remote access without exposing anything publicly — more reliably, and honestly with less setup than the VPN route.
The idea: cloudflared runs on your server and opens an outbound-only connection to Cloudflare. Nothing needs to be reachable from the internet for this to work — no inbound port, no forwarding. Cloudflare routes traffic to your tunnel, your tunnel routes it to whatever's listening locally.
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
cloudflared tunnel login
cloudflared tunnel create my-server-tunnel
That last command prints a tunnel ID and writes a credentials file to ~/.cloudflared/. Create the config file:
sudo nano /etc/cloudflared/config.yml
tunnel: <your-tunnel-id>
credentials-file: /home/deploy/.cloudflared/<your-tunnel-id>.json
ingress:
- hostname: coolify.yourdomain.com
service: http://localhost:8000
- hostname: ssh.yourdomain.com
service: ssh://localhost:22
- service: http_status:404
The order matters, and that last catch-all line isn't optional — cloudflared won't start without a final rule that matches anything not already covered. Route the DNS for both hostnames, which creates the CNAME records in Cloudflare automatically:
cloudflared tunnel route dns my-server-tunnel coolify.yourdomain.com
cloudflared tunnel route dns my-server-tunnel ssh.yourdomain.com
Then install it as a service so it survives reboots:
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared
On the machine you'll actually be connecting from, install cloudflared too, and add this to ~/.ssh/config:
Host ssh.yourdomain.com
User deploy
ProxyCommand cloudflared access ssh --hostname %h
From here, ssh ssh.yourdomain.com tunnels through Cloudflare instead of hitting the server's public IP directly. Same idea for the dashboard — visiting coolify.yourdomain.com now routes through the tunnel rather than a direct A record.
9. Gate both hostnames behind Cloudflare Zero Trust Access
The tunnel alone just moves the traffic — it doesn't check who's on the other end. That's Access's job. In the Zero Trust dashboard, under Access → Applications, create an application for each hostname (coolify.yourdomain.com and ssh.yourdomain.com), and attach a policy: allow specific emails or your email domain, require a one-time PIN or your identity provider of choice.
Two things worth double-checking here, because both will quietly lock you out if you miss them: the hostname in your tunnel config, the DNS record, and the Access application all have to match exactly — a trailing difference between any of them breaks the chain. And an Access application with no policy attached blocks everyone, including you, not just uninvited guests.
Once both applications are live, hitting either hostname prompts for authentication before the connection is allowed anywhere near your server. The dashboard and SSH are now sitting behind two layers before anyone reaches them: the tunnel itself, and Access in front of it.
10. Tighten the firewall now that the tunnel is doing the work
With SSH and the dashboard both reachable through the tunnel, there's no reason to keep them exposed on the public internet too. Pull the SSH rule from both firewall layers:
sudo ufw delete allow 22/tcp
Remove the matching rule from your provider's edge firewall as well. Test the tunnel connection thoroughly before you do this — confirm ssh ssh.yourdomain.com works cleanly from a fresh terminal first, since this step removes your fallback. What's left open publicly after this is just 80 and 443, for the actual sites Coolify serves. The dashboard and SSH aren't reachable by IP at all anymore, tunnel or nothing.
11. Turn on backups
Enable your provider's automatic server snapshots before you deploy anything real. It's the cheapest insurance you'll buy all year.
Where you should be now
- A server with no direct public SSH access — reachable only through the Cloudflare Tunnel, gated by Zero Trust Access
- fail2ban active and watching auth attempts
- Only ports 80 and 443 open to the public internet
- Coolify installed, reachable at your own domain through the tunnel, with SSL
- Automatic server backups turned on
If any of these boxes aren't checked, it's worth going back before moving on — everything in the rest of this series assumes this part is solid.
The rest of the series
This is one server doing one thing so far. Here's where it's going:
- The server — Provisioning a VPS, hardening it, and putting Coolify behind a Cloudflare Tunnel. This part.
- The frontend — Deploying a frontend app to Coolify with zero downtime, alongside wherever it's currently hosted. Framework-agnostic — whatever you're actually running.
- The backend — Self-hosting your backend of choice, and what actually breaks when you do.
- The database — Migrating real data without losing any of it, and building a backup habit around it.
- Staying in control — Monitoring resources, catching problems early, and knowing when it's time to resize.
The server's ready, and this time it's actually locked down the way it should be. Next part: taking a frontend off a managed platform and running it here instead — without the site ever going down in the process.
Top comments (0)