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

  1. Go to tailscale.com/download
  2. Download for your OS (Mac, Windows, Linux, iOS, Android)
  3. Install and open it
  4. 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:

  1. ssh root@YOUR_SERVER_IP - still works
  2. http://YOUR_TAILSCALE_IP:8000 - Coolify UI loads
  3. Your public apps work
  4. 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, Cloudflare
  • nocodb.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 publicly
  • bind-interfaces - don't try to bind 0.0.0.0:53 (would conflict with systemd-resolved)
  • address= - wildcard: any subdomain of internal.yourdomain.com resolves 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

  1. Go to login.tailscale.com/admin
  2. Click DNS in the left sidebar
  3. Scroll down to Nameservers
  4. Click Add nameserver > Custom
  5. Enter YOUR_TAILSCALE_IP
  6. Toggle Restrict to search domain
  7. Enter internal.yourdomain.com
  8. Save
  9. 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:

  1. Open your Coolify dashboard (http://YOUR_TAILSCALE_IP:8000)
  2. Click on the service (e.g., NocoDB)
  3. Click Advanced in the left sidebar
  4. Find the DNS validation checkbox and uncheck it
  5. Save
  6. Go back to the service's main settings
  7. Change the domain from https://oldname.yourdomain.com to http://<name>.internal.yourdomain.com
  8. Click Save then Redeploy
  9. Wait for the deploy to finish, then open http://<name>.internal.yourdomain.com in your browser
  10. 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:

  1. Go to one.dash.cloudflare.com (Zero Trust dashboard)
  2. Access > Applications > Add an application > Self-hosted
  3. 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 deny does nothing for Docker ports. Use DOCKER-USER chain 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 mDNSResponder and reconnect Tailscale.
  • Disable Coolify DNS validation for private domains. It checks public DNS which will always fail.

Sources