Securing Coolify with Tailscale, UFW & Cloudflare
I run Coolify on a Hetzner bare metal server to host multiple web apps I have built and the services I use to maintain them. Of course almost none of my sites have any users, but I enjoy the process, and that is not here or there (but if you want to check one out - look at https://heyiam.com!). Out of the box, every service you deploy is publicly accessible. Your database dashboard, log viewer, admin tools - all sitting on public subdomains for anyone to find. That's not great.
This guide covers how I lock it down my server into two tiers:
- Public services (apps, marketing sites) stay on Cloudflare
- Internal services (dashboards, admin tools) only accessible over Tailscale
Prerequisites
- A server running Coolify (I use Ubuntu 24.04 on Hetzner, any Linux VPS works)
- A domain on Cloudflare
- A local machine to access services from
Replace these placeholders with your own values:
| Placeholder | Meaning | Example |
|---|---|---|
YOUR_SERVER_IP |
Server's public IP | 203.0.113.50 |
YOUR_TAILSCALE_IP |
Server's Tailscale IP (starts with 100.) |
100.64.0.12 |
yourdomain.com |
Your Cloudflare domain | example.com |
Part 1: Tailscale
Tailscale is a mesh VPN built on WireGuard. You sign in with Google/GitHub, install it on your devices, and they can all talk to each other over an encrypted network. No port forwarding, no key management, no pain.
Free for up to 100 devices, 3 users. No credit card.
Install on your local machine
- Go to tailscale.com/download
- Download for your OS (Mac, Windows, Linux, iOS, Android)
- Install and open it
- Sign in with Google, GitHub, Microsoft, or Apple - no separate account needed
Install on the Coolify server
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
It gives you a URL to authenticate. Open it, approve the device.
Get your Tailscale IP
tailscale ip
Save this 100.x.x.x address. You'll use it everywhere.
Test the connection
From your local machine:
ssh root@YOUR_TAILSCALE_IP
And open http://YOUR_TAILSCALE_IP:8000 in your browser. If both work, you're connected.
Optional: Tailscale SSH
sudo tailscale up --ssh
This lets you SSH with your Tailscale identity instead of keys. Nice to have, not required.
Two things to know
HTTP is fine over Tailscale. WireGuard encrypts everything. HTTPS would be double encryption for no reason.
Tailscale bypasses your firewall. It uses its own network interface (tailscale0), separate from your public interface. Firewall rules don't touch it. This is what makes the whole setup work - you can block a port publicly and still reach it over Tailscale.
Part 2: Block the Coolify UI publicly
Now that Tailscale works, block public access to Coolify.
Docker bypasses UFW
This tripped me up. ufw deny 8000 does nothing for Docker containers. Docker inserts its own iptables rules that skip UFW entirely. The only way to block Docker ports is through the DOCKER-USER chain.
Quick test
iptables -I DOCKER-USER -p tcp --dport 8000 -j DROP
iptables -I DOCKER-USER -p tcp --dport 8080 -j DROP
Coolify maps 8000 to 8080 internally, so block both.
Check that http://YOUR_SERVER_IP:8000 stops loading but http://YOUR_TAILSCALE_IP:8000 still works.
These rules disappear on reboot. Part 3 makes them permanent.
Part 3: UFW
UFW and iptables-persistent conflict (installing one removes the other). I went with UFW because the syntax is simpler.
Install
apt remove iptables-persistent netfilter-persistent -y
apt install ufw -y
ufw reset
Defaults
ufw default deny incoming
ufw default allow outgoing
Allow ports BEFORE enabling
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw allow 443/udp comment 'QUIC'
Port 80/443 need to stay open because all Coolify services route through Traefik on these ports. We control access via DNS, not ports.
Allow SSH first or you lock yourself out.
Make Docker port blocking persistent
UFW loads /etc/ufw/after.rules on boot. Add your DOCKER-USER rules there, after the existing COMMIT line:
echo '' >> /etc/ufw/after.rules
echo '# Docker port blocking (Docker bypasses ufw)' >> /etc/ufw/after.rules
echo '*filter' >> /etc/ufw/after.rules
echo ':DOCKER-USER - [0:0]' >> /etc/ufw/after.rules
echo '-A DOCKER-USER -p tcp --dport 8000 -j DROP' >> /etc/ufw/after.rules
echo '-A DOCKER-USER -p tcp --dport 8080 -j DROP' >> /etc/ufw/after.rules
echo '-A DOCKER-USER -j RETURN' >> /etc/ufw/after.rules
echo 'COMMIT' >> /etc/ufw/after.rules
The -j RETURN is important. Without it, all Docker traffic gets dropped, including your public apps.
I used echo instead of a heredoc (cat << EOF) because heredocs can hang over SSH.
Enable
ufw enable
Verify (don't close your SSH session yet)
Open a new terminal and check:
ssh root@YOUR_SERVER_IP- still workshttp://YOUR_TAILSCALE_IP:8000- Coolify UI loads- Your public apps work
http://YOUR_SERVER_IP:8000- blocked
If something breaks, your old session is still alive. Run ufw disable.
Safety net: Most providers (Hetzner, DigitalOcean, etc.) have a web console in their dashboard. Know where it is before you start.
Part 4: Cloudflare
Public apps stay on Cloudflare. The architecture looks like this:
Public traffic -> Cloudflare -> server:80/443 -> Traefik -> app container
Admin traffic -> Tailscale -> server:8000 -> Coolify UI
Private tools -> Tailscale -> server:80 -> Traefik -> tool container
DNS records
In your Cloudflare dashboard, click your domain, then DNS > Records in the left sidebar:
- Public apps: Keep their DNS records with the orange cloud (Proxied). They get DDoS protection, caching, SSL.
- Coolify UI: No DNS record. Use
http://YOUR_TAILSCALE_IP:8000. - Private services: No public DNS records. We'll handle these with Tailscale Split DNS in Part 5.
Optional: Lock ports 80/443 to Cloudflare IPs
If someone finds your server's real IP, they can bypass Cloudflare. Fix that by only allowing Cloudflare's IP ranges.
Current ranges (check cloudflare.com/ips/ for updates):
ufw allow from 173.245.48.0/20 to any port 80,443 proto tcp
ufw allow from 103.21.244.0/22 to any port 80,443 proto tcp
ufw allow from 103.22.200.0/22 to any port 80,443 proto tcp
ufw allow from 103.31.4.0/22 to any port 80,443 proto tcp
ufw allow from 104.16.0.0/13 to any port 80,443 proto tcp
ufw allow from 104.24.0.0/14 to any port 80,443 proto tcp
ufw allow from 108.162.192.0/18 to any port 80,443 proto tcp
ufw allow from 131.0.72.0/22 to any port 80,443 proto tcp
ufw allow from 141.101.64.0/18 to any port 80,443 proto tcp
ufw allow from 162.158.0.0/15 to any port 80,443 proto tcp
ufw allow from 172.64.0.0/13 to any port 80,443 proto tcp
ufw allow from 188.114.96.0/20 to any port 80,443 proto tcp
ufw allow from 190.93.240.0/20 to any port 80,443 proto tcp
ufw allow from 197.234.240.0/22 to any port 80,443 proto tcp
ufw allow from 198.41.128.0/17 to any port 80,443 proto tcp
Then remove the generic rules:
ufw delete allow 80/tcp
ufw delete allow 443/tcp
Tailscale traffic bypasses UFW, so private services still work.
Part 5: Private services with Tailscale + dnsmasq
This is the interesting part. We want internal services to be accessible at normal URLs like nocodb.internal.yourdomain.com, but only from devices on our tailnet.
The idea
myapp.yourdomain.com-> public, Cloudflarenocodb.internal.yourdomain.com-> private, Tailscale only
Why you need subdomains
Coolify uses Traefik as a reverse proxy. All services share port 80. Traefik looks at the Host header to decide which container gets the request. If you just visit http://YOUR_TAILSCALE_IP, Traefik doesn't know what to do with it.
This also means you can't block individual services by port. They all share port 80. Access control is at the DNS level: if the domain doesn't resolve publicly, nobody can reach it.
Why you need dnsmasq
I spent a while figuring this out. Tailscale Split DNS doesn't map a domain to an IP. When you add internal.yourdomain.com -> YOUR_TAILSCALE_IP in the Tailscale admin, it means "forward DNS queries for that domain to the DNS server at that IP."
It's treating the IP as a nameserver. If there's no DNS server running there, queries time out.
dnsmasq is a tiny DNS server (~1MB RAM) that answers with a wildcard: any *.internal.yourdomain.com resolves to your Tailscale IP. It only listens on the Tailscale interface, so it doesn't conflict with anything.
Step 1: Install dnsmasq
apt install dnsmasq -y
If it conflicts with systemd-resolved:
systemctl disable --now systemd-resolved
This is fine. Your server's outbound DNS still works through /etc/resolv.conf.
Step 2: Configure
Three lines:
echo 'listen-address=YOUR_TAILSCALE_IP' > /etc/dnsmasq.d/internal.conf
echo 'bind-interfaces' >> /etc/dnsmasq.d/internal.conf
echo 'address=/internal.yourdomain.com/YOUR_TAILSCALE_IP' >> /etc/dnsmasq.d/internal.conf
listen-address- only listen on the Tailscale interface, not publiclybind-interfaces- don't try to bind0.0.0.0:53(would conflict with systemd-resolved)address=- wildcard: any subdomain ofinternal.yourdomain.comresolves to your Tailscale IP. One line, infinite subdomains.
systemctl restart dnsmasq
systemctl enable dnsmasq
Step 3: Test on the server
dig anything.internal.yourdomain.com @YOUR_TAILSCALE_IP
Should return your Tailscale IP.
Step 4: Tailscale Split DNS
- Go to login.tailscale.com/admin
- Click DNS in the left sidebar
- Scroll down to Nameservers
- Click Add nameserver > Custom
- Enter
YOUR_TAILSCALE_IP - Toggle Restrict to search domain
- Enter
internal.yourdomain.com - Save
- Scroll down and make sure MagicDNS is enabled (toggle it on if not)
Now every device on your tailnet sends *.internal.yourdomain.com queries to dnsmasq.
Step 5: Test from your machine
Reconnect Tailscale (disconnect/reconnect). On macOS, flush DNS:
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
Then:
dig anything.internal.yourdomain.com @100.100.100.100
100.100.100.100 is Tailscale's DNS resolver. If it returns your Tailscale IP, it's working.
Step 6: Move services in Coolify
Coolify validates domains against public DNS by default. Since *.internal.yourdomain.com doesn't exist publicly, this fails. You need to disable validation first.
For each service you want to make private:
- Open your Coolify dashboard (
http://YOUR_TAILSCALE_IP:8000) - Click on the service (e.g., NocoDB)
- Click Advanced in the left sidebar
- Find the DNS validation checkbox and uncheck it
- Save
- Go back to the service's main settings
- Change the domain from
https://oldname.yourdomain.comtohttp://<name>.internal.yourdomain.com - Click Save then Redeploy
- Wait for the deploy to finish, then open
http://<name>.internal.yourdomain.comin your browser - If it works, go to Cloudflare > DNS > Records and delete the old DNS record for that service
Do them one at a time so you can verify each works.
Use HTTP, not HTTPS. Tailscale already encrypts everything, and Let's Encrypt can't issue certs for domains that don't resolve publicly.
Future services
Just set the domain in Coolify to http://whatever.internal.yourdomain.com. dnsmasq handles it automatically. No config changes needed.
The full flow
http://nocodb.internal.yourdomain.com
1. DNS:
Browser asks "what IP is this?"
-> Tailscale intercepts (Split DNS)
-> Forwards to dnsmasq on your server
-> dnsmasq answers with your Tailscale IP
2. Network:
Browser connects to YOUR_TAILSCALE_IP:80
-> Goes through WireGuard tunnel
-> Arrives at server
3. Routing:
Traefik gets the request on port 80
-> Sees Host header "nocodb.internal.yourdomain.com"
-> Routes to the right container
The domain doesn't exist in public DNS. Only your tailnet devices can resolve it.
Cloudflare Access (if a service must stay public)
Some things need to be publicly reachable, like an API that frontend apps call. You can put those behind Cloudflare Access:
- Go to one.dash.cloudflare.com (Zero Trust dashboard)
- Access > Applications > Add an application > Self-hosted
- Add your subdomain and a policy (e.g., email OTP)
Free for up to 50 users.
Worth adding too
- CrowdSec - blocks known malicious IPs automatically
- Fail2ban - bans IPs after failed SSH attempts
Final setup
| Traffic | Path | Protected by |
|---|---|---|
| Public apps | Cloudflare -> server:80/443 -> Traefik -> container | Cloudflare, UFW |
| Internal tools | Tailscale -> server:80 -> Traefik -> container | Tailscale, no public DNS |
| Coolify UI | Tailscale -> server:8000 | Tailscale, port blocked |
| SSH | Tailscale or public:22 | Tailscale, fail2ban |
Gotchas
- Docker bypasses UFW.
ufw denydoes nothing for Docker ports. UseDOCKER-USERchain in/etc/ufw/after.rules. - Tailscale bypasses UFW. By design. That's how private services stay accessible.
- Split DNS needs an actual DNS server. It forwards queries, it doesn't create records. You need dnsmasq or similar.
- UFW and iptables-persistent conflict. Pick one.
- Heredocs can hang over SSH. Use
echo >>instead. - Allow SSH before enabling UFW. Otherwise you're locked out.
- Flush DNS on macOS.
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponderand reconnect Tailscale. - Disable Coolify DNS validation for private domains. It checks public DNS which will always fail.