Two machines: one runs the Coolify panel, the other runs the apps. The panel has to be reachable from anywhere, which usually means a login screen exposed to the whole internet. What follows is the setup that made that access usable and kept the login out of scanners' reach.
No single layer does the job alone. Cloudflare hides the origin, the Hetzner firewall lets only Cloudflare in, and the host accepts neither passwords nor public SSH. If one layer fails, the others stay up.
The current state, checked
| Layer | Configuration | State |
|---|---|---|
| DNS | 2 records, both proxied (orange cloud) | active |
| Hetzner firewall | 80/443 Cloudflare IPs only · SSH from the tailnet | active |
| SSH | key only, no password, root by key | active |
| WAF | Managed Challenge on the panel host | active |
| 2FA | 3 of 3 users | active |
| Auto upgrades | daily unattended-upgrades | active |
| Tailscale | 3 devices | active |
| Docker ports | 8000/6001/6002/8080 on 0.0.0.0 | accepted |
1. Hide the origin IP behind Cloudflare
Point DNS at the VPS IPs with the orange proxy on. DNS then answers with Cloudflare IPs; your IPs only show up in the Hetzner console.
In my case there are only two records, and both go through the proxy:
A coolify.yourdomain.com → 203.0.113.10 proxied = true
A *.yourdomain.com → 198.51.100.20 proxied = true
Without this, anyone who finds the IP through DNS skips every layer below. Grey cloud (DNS only) is the most common mistake, and the most fatal.
For verification, the record should answer with a Cloudflare IP, never yours.
dig +short coolify.yourdomain.com
# should return a Cloudflare IP, never 203.0.113.10
2. Close the Hetzner firewall to everyone but Cloudflare
The Hetzner Cloud firewall sits at the VM edge, before the operating system. Three rules carry the blocking:
inbound TCP 80 ← Cloudflare IP ranges only
inbound TCP 443 ← Cloudflare IP ranges only
inbound TCP 22 ← 100.64.0.0/10 only (Tailscale)
These ranges are the official ones published at cloudflare.com/ips: 15 IPv4 blocks, from 173.245.48.0/20 to 131.0.72.0/22. Paste the full list and review it when Cloudflare publishes changes.
On the apps machine, one extra rule needs an explanation:
| Rule | Reason |
|---|---|
TCP 22 from 203.0.113.10/32 | Coolify deploys to the apps over SSH using the public IP. Without this rule, deploys break. |
The panel server also has UDP 41641 open for Tailscale's WireGuard, which allows direct connections instead of relay.
From outside, every port should look closed. Only Cloudflare reaches 80/443.
for p in 22 80 443 8000 6001 6002 8080; do
nc -z -G 5 203.0.113.10 $p && echo "$p OPEN" || echo "$p filtered"
done
3. Take SSH off the internet and route it through Tailscale
The SSH daemon still listens on all interfaces, but the Hetzner firewall only lets in 100.64.0.0/10, Tailscale's internal range (plus the panel IP on the apps box, for deploys). The daemon also refuses passwords:
PasswordAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
Tailscale connects from the inside out (WireGuard + NAT traversal), so it works even with all public inbound traffic blocked. You get in; a scanner does not even see the port.
The panel also answers at http://100.101.102.103:8000. If Cloudflare or the Hetzner firewall has a problem, admin access stays alive.
4. Put the panel behind a human challenge
A Cloudflare WAF rule on the panel hostname with the Managed Challenge action. A real visitor barely notices; bots and scanners stop before reaching the login.
(http.host eq "coolify.yourdomain.com"
and not starts_with(http.request.uri.path, "/webhooks/")
and not (starts_with(http.request.uri.path, "/api/")
and not (http.request.uri.path contains ".")))
The request never reaches Coolify. A new flaw in the product does not help anyone who cannot get to it.
The rule has two exceptions, each for a concrete reason:
/webhooks/*: GitHub cannot solve a captcha. The webhook validates HMACX-Hub-Signature-256; without a signature it does not trigger a deploy./api/with no dot in the path: Sentinel, the CLI and integrations need to get through. All 425 Coolify API routes were checked and none has a dot in the path./api/v1/.envand/api/phpinfo.phpdo, so they get the challenge.
The test results:
| Request | Response |
|---|---|
/login | 403 + challenge |
/api/.env · /api/v1/.env · /api/phpinfo.php | 403 + challenge |
/api/health | 200 |
/api/v1/version without a token | 401 (normal) |
POST /webhooks/source/github/events | 200 |
5. Raise Challenge Passage to 2 hours
Cloudflare expires the challenge after 30 minutes by default. The Coolify panel fires background requests constantly (Livewire, polling, assets), so the tab asks for verification again mid-task.
At 7200 seconds, the challenge stays invisible for the whole session.
6. Turn on 2FA for every account
Three users, three with TOTP confirmed. Even if someone guesses a password, the second factor is missing.
To check in Coolify:
docker exec coolify php artisan tinker --execute='
foreach (App\Models\User::all() as $u) {
echo $u->email." 2fa=".($u->two_factor_confirmed_at ? "yes" : "no").PHP_EOL;
}'
7. Automate security updates
unattended-upgrades installs updates from the main and security pockets without manual work. On both machines it is installed, enabled and active:
ii unattended-upgrades 2.12ubuntu9
enabled
active
The two directives that matter live in /etc/apt/apt.conf.d/20auto-upgrades:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
The allowed origins in 50unattended-upgrades cover the essentials:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
The log confirms it runs on its own every morning:
2026-11-06 06:21:11 Packages that will be upgraded: apt libapt-pkg7.0 python3-twisted
2026-11-06 06:21:27 All upgrades installed
2026-11-07 06:16:31 Packages that will be upgraded: ca-certificates
2026-11-07 06:16:38 All upgrades installed
Running on its own does not mean always current: when I checked, the apps box still had 4 pending security updates. A kernel update also only takes effect after a reboot. The default is Automatic-Reboot "false", so a machine can run an old kernel for weeks. If automatic reboots are acceptable, turn it on in a maintenance window:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
Unattended-Upgrade::Mail "you@example.com";
Unattended-Upgrade::MailReport "on-change";
On a container host, a reboot drops everything for a few seconds. Coolify comes back up, but the window has to be chosen. A more conservative option is to keep reboots manual and check /var/run/reboot-required every week.
To check:
systemctl list-timers 'apt-daily*' 'unattended-upgrades*'
unattended-upgrade --dry-run -d
tail -20 /var/log/unattended-upgrades/unattended-upgrades.log
test -f /var/run/reboot-required && cat /var/run/reboot-required
That covers the operating system. Coolify and the applications have their own release cycle.
8. Keep the app current and rely on the built-in rate limit
Coolify is on v4.7.5. The product has had an authentication bypass CVE before, so the version matters.
Login also has its own limit: 5 attempts per minute (app/Providers/RouteServiceProvider.php, checked on v4.6.0). With 2FA on top, password guessing stops being a realistic path.
9. Keep Tailscale as a fallback and prune devices
The tailnet has exactly three devices:
| Device | Tailscale IP |
|---|---|
| coolify | 100.101.102.103 |
| apps | 100.104.105.106 |
| personal device | 100.107.108.109 |
A forgotten device on the tailnet still has access to the panel. I removed the old and inactive ones; the server key expires in April 2027. Review the list from time to time.
10. Measure the effect in the logs
Traefik's access.log shows what reached the server. Cloudflare's security events show what was blocked before.
| Metric | Before | After |
|---|---|---|
| Probes that reached the server | 3,775 in ~4.9 days | 3 (leftover from the switch) |
| POST /login from unknown IPs | 11 (all failed) | 0 |
| Cloudflare blocks (24h) | n/a | 55 |
The attack did not stop. The difference is that it now gets blocked at Cloudflare, and almost all traffic that reaches the server is legitimate.
Checklist for an agent
An agent with SSH access to both servers, hcloud and a read-only Cloudflare token can run these checks. All of them are read-only. Replace the example IPs and domain with your own.
- DNS is proxied. The answer must be a Cloudflare IP, never the origin.
dig +short coolify.yourdomain.com - No port is reachable from the internet. Run from a machine outside the tailnet; every line should say filtered, for both servers.
for ip in 203.0.113.10 198.51.100.20; do for p in 22 80 443 8000 6001 6002 8080; do nc -z -G 5 $ip $p && echo "$ip:$p OPEN" || echo "$ip:$p filtered" done done - The origin cannot be reached by skipping Cloudflare. This must time out.
curl -sk -m 10 --resolve coolify.yourdomain.com:443:203.0.113.10 \ https://coolify.yourdomain.com/login -o /dev/null -w '%{http_code}\n' - The WAF challenges the panel and lets machine traffic through. Expected: /login and /api/.env give 403 with cf-mitigated: challenge, /api/health gives 200, /api/v1/version gives 401, the webhook gives 200.
P=https://coolify.yourdomain.com for u in /login /api/.env /api/health /api/v1/version; do echo "$u $(curl -s -o /dev/null -w '%{http_code}' $P$u)" \ "$(curl -sI $P$u | grep -i '^cf-mitigated' | tr -d '\r')" done curl -s -o /dev/null -w 'webhook %{http_code}\n' -X POST $P/webhooks/source/github/events - The Hetzner firewall matches Cloudflare's current list. The diff must be empty, and port 22 must only allow 100.64.0.0/10 (plus the panel IP on the apps firewall).
curl -s https://www.cloudflare.com/ips-v4 | sort > cf.txt hcloud firewall describe <firewall-name> -o json \ | jq -r '.rules[] | select(.port=="443" and .protocol=="tcp") | .source_ips[]' | sort > hz.txt diff cf.txt hz.txt hcloud firewall describe <firewall-name> -o json | jq -r '.rules[] | select(.port=="22") | .source_ips[]' - SSH refuses passwords. Run on each server.
sshd -T | grep -E '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication|maxauthtries)' - The tailnet only has the expected devices.
tailscale status - Automatic updates run and no reboot is waiting.
systemctl is-active unattended-upgrades tail -5 /var/log/unattended-upgrades/unattended-upgrades.log test -f /var/run/reboot-required && cat /var/run/reboot-required || echo 'no reboot pending' - Every Coolify user has 2FA.
docker exec coolify php artisan tinker --execute=' foreach (App\Models\User::all() as $u) { echo $u->email." 2fa=".($u->two_factor_confirmed_at ? "yes" : "no").PHP_EOL; }' - Coolify version and container health. The second command should print nothing.
docker inspect coolify --format '{{.Config.Image}}' docker ps -a --format '{{.Names}} {{.Status}}' | grep -iE 'unhealthy|restarting|exited' - Sentinel still reports. The last push should be 200 and less than 3 minutes old.
grep sentinel/push /data/coolify/proxy/access.log | tail -1 \ | jq -r '.StartUTC + " " + (.DownstreamStatus|tostring)' - No probes reached the server. Count requests to the panel with probe paths; the result should be 0 or close to it.
jq -r 'select(.RequestHost=="coolify.yourdomain.com") | .RequestPath' \ /data/coolify/proxy/access.log | grep -cE '\.env|\.php|\.git|/wp-' - Cloudflare is blocking. Query the security events for the WAF rule over the last 24 hours with the GraphQL API (firewallEventsAdaptive, filtered by ruleId). Read the token from an environment variable and never print it.
If any check fails, the agent should report the command, the output and the expected result, and change nothing.
Known, accepted risk
Ports 8000, 6001, 6002 and 8080 listen on 0.0.0.0 inside the panel VPS. Right now only the Hetzner firewall blocks them. If it is edited by mistake, the panel becomes reachable directly at 203.0.113.10:8000, with no Cloudflare and no challenge.
I rate this as low risk, for these reasons:
- Exposure needs a mistake first. The Hetzner firewall applies at the VM edge, and a scan from outside shows all four ports as filtered. Someone would have to delete or detach that firewall before anything opens.
- The login is still protected. All three accounts use 2FA, and login is limited to 5 attempts per minute, so a password alone does not get anyone in.
- The other ports expose little. 6001 and 6002 are the websocket and terminal backends, and both need a logged-in session. Nothing answers on 8080, even from inside the server.
- Nothing in the panel reaches the internet through these ports. Traefik talks to Coolify over the internal Docker network (
http://coolify:8080), so these bindings only serve the Tailscale fallback.
The chance of breaking the firewall by mistake is small because the firewall rarely needs touching. Its rules are static, and the only list that could force an edit is Cloudflare's IP ranges, which almost never change. The IPv4 list last changed in April 2021, when 104.16.0.0/12 was split into 104.16.0.0/13 and 104.24.0.0/14, which cover the same addresses. The change before that was in June 2017. Cloudflare also adds new ranges to cloudflare.com/ips before putting them into production, so there is time to update the firewall.
Verified on 11 November 2026: Ubuntu 26.04.1 LTS, kernel 7.0.0-42, Coolify 4.7.5, 7200 s challenge TTL, 2FA on 3/3 users, unattended-upgrades active on both machines.