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
Small disclaimer: This is not official security advice. It is just the setup I use for my own self-hosted projects. It works for my threat model, but don’t blindly copy it if you are hosting something important, sensitive, or used by real customers.
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 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. 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, 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. 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 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.
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 denyoften does not affect published container ports the way you expect. UseDOCKER-USERchain 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 mDNSResponderand reconnect Tailscale. - Disable Coolify DNS validation for private domains. It checks public DNS which will always fail.