Guide · Coolify · 10 steps

Coolify + VPS lockdown guide

How I locked down a self-hosted Coolify panel and its Hetzner VPS: Cloudflare, an edge firewall, Tailscale, WAF, 2FA and automatic updates.

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

LayerConfigurationState
DNS2 records, both proxied (orange cloud)active
Hetzner firewall80/443 Cloudflare IPs only · SSH from the tailnetactive
SSHkey only, no password, root by keyactive
WAFManaged Challenge on the panel hostactive
2FA3 of 3 usersactive
Auto upgradesdaily unattended-upgradesactive
Tailscale3 devicesactive
Docker ports8000/6001/6002/8080 on 0.0.0.0accepted

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:

RuleReason
TCP 22 from 203.0.113.10/32Coolify 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:

The test results:

RequestResponse
/login403 + challenge
/api/.env · /api/v1/.env · /api/phpinfo.php403 + challenge
/api/health200
/api/v1/version without a token401 (normal)
POST /webhooks/source/github/events200

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:

DeviceTailscale IP
coolify100.101.102.103
apps100.104.105.106
personal device100.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.

MetricBeforeAfter
Probes that reached the server3,775 in ~4.9 days3 (leftover from the switch)
POST /login from unknown IPs11 (all failed)0
Cloudflare blocks (24h)n/a55

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.

  1. DNS is proxied. The answer must be a Cloudflare IP, never the origin.
    dig +short coolify.yourdomain.com
  2. 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
  3. 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'
  4. 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
  5. 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[]'
  6. SSH refuses passwords. Run on each server.
    sshd -T | grep -E '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication|maxauthtries)'
  7. The tailnet only has the expected devices.
    tailscale status
  8. 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'
  9. 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;
    }'
  10. 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'
  11. 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)'
  12. 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-'
  13. 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:

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.