How I setup my Hetzner server with Coolify, Tailscale, UFW, and Cloudflare
quality 7/10 · good
0 net
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, Coolify makes it very easy to expose services publicly. 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. 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 For my setup , I’m comfortable using HTTP over Tailscale because the traffic is already encrypted by WireGuard and only reachable inside the tailnet.. Tailscale traffic arrives over a different interface , so rules aimed at your public interface do not behave the same way. 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. With UFW on a Docker host, the most reliable place to block published Docker ports is 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 I would avoid mixing UFW and iptables-persistent casually. 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' Inspect your existing /etc/ufw/after.rules before appending this blindly. 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 works http://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. One way to reduce that risk is to allow only Cloudflare’s IP ranges to reach 80/443. 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. Private DNS is part of the convenience layer here. The real security boundary is that these services are only reachable over Tailscale. Why you need dnsmasq I spent a while figuring this out. Tailscale Split DNS does not mean "map this domain directly to this IP." It means "for this domain, forward DNS queries to the DNS server at this 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 On my setup, outbound DNS kept working through /etc/resolv.conf , but check your resolver state before assuming that on every distro or image. 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 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.com to http://.internal.yourdomain.com Click Save then Redeploy Wait for the deploy to finish, then open http://.internal.yourdomain.com in 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. I use HTTP for these Tailscale-only internal services because the tailnet is already encrypted and public ACME validation does not fit these private names cleanly. 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 internet-reachable but should still require auth) 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. With Docker on a host ufw deny often does not affect published container ports the way you expect. Use DOCKER-USER chain in /etc/ufw/after.rules . Tailscale bypasses UFW. Tailscale traffic takes a different path than your public interface traffic, which is why public firewall expectations can be misleading here. 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 Tailscale DNS docs What is Split DNS Split DNS + dnsmasq Tailscale custom domains Coolify + Tailscale on Hetzner Coolify DNS docs Cloudflare Access Cloudflare IP ranges