2026.07 / ADMIN SECURITY CHECKLIST 025
Put Coolify admin behind Cloudflare Access without breaking realtime logs
Coolify is comfortable enough to run as the control panel for a small VPS, but the dashboard is still an admin surface. If it is reachable on a raw public port, every scanner on the internet can find the login page, try old CVEs, and fill the logs. The better pattern is to keep public apps on normal HTTPS, move the Coolify dashboard behind Cloudflare Tunnel plus Cloudflare Access, and only then close the raw dashboard and websocket ports.
The tricky part is not the first page load. It is the realtime layer. A Coolify screen can appear to work while live deployment logs, terminal sessions, or server events fail because websocket traffic is still pointing at the wrong host or port. This checklist is for the long-tail query I kept running into: how do I protect Coolify admin with Cloudflare Access without breaking realtime logs?
Goal: reach Coolify through an identity-gated hostname, preserve live logs and terminal websockets, and remove direct public exposure of the admin ports after the replacement path is proven.
The safe target architecture
Keep the public application path boring: user traffic to your hosted apps still goes through Cloudflare DNS, Traefik or nginx, and ports 80/443. The admin path is separate:
browser
↓ HTTPS + Cloudflare Access policy
coolify-admin.example.com
↓ Cloudflare Tunnel outbound connector
127.0.0.1:8000 # dashboard
127.0.0.1:6001 # realtime websocket
127.0.0.1:6002 # terminal websocket
The important design choice is that the tunnel points at loopback services on the VPS. The tunnel connector makes an outbound connection to Cloudflare; the VPS does not need to accept raw public connections to the admin ports. Cloudflare's Tunnel documentation describes this connector model, and Cloudflare Access's self-hosted application docs cover the identity gate that sits in front of the hostname. Coolify's own docs and troubleshooting notes are the reminder that websocket/realtime traffic may need explicit routing, not just the dashboard port.
1. Inventory before changing anything
Do not start by closing ports. First prove what is running and what readers of the dashboard currently depend on:
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/health
ss -tulpn | grep -E ':(8000|6001|6002)\\b'
Those commands are intentionally local and generic. The public article does not need your VPS IP, token names, private key paths, or firewall provider details. It only needs the shape of the check: dashboard health, realtime ports, and current listening addresses.
2. Create a tunnel hostname for the dashboard
In Cloudflare Zero Trust, create a Cloudflared tunnel and install the connector on the VPS. For a Docker-based host, the dashboard usually gives a command in this shape:
docker run -d --name cloudflared \
--restart unless-stopped \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run \
--token YOUR_TUNNEL_TOKEN
Keep the token out of Git and out of screenshots. Then add the first public hostname:
Hostname: coolify-admin.example.com
Path: (blank)
Service: http://127.0.0.1:8000
If the dashboard loads through the tunnel, add Cloudflare Access as a self-hosted application for that hostname. The policy should allow your email address or identity group and deny everyone else. At this point the hostname should show an Access login before it shows Coolify.
3. Add websocket routes before declaring victory
This is the step people miss. Coolify's dashboard can use separate websocket services for live updates and terminal sessions. Depending on the installed version and settings, there are two practical patterns.
Option A: separate realtime hostnames
This is easiest to reason about because each hostname maps to one local service:
coolify-admin.example.com → http://127.0.0.1:8000
coolify-realtime.example.com → http://127.0.0.1:6001
coolify-terminal.example.com → http://127.0.0.1:6002
Then set Coolify's public app URL and realtime/websocket host settings to the HTTPS hostnames you actually use. The exact variable names can vary by release, so read the installed environment file and Coolify docs for your version before editing. The principle is stable: browser websocket URLs must point at Access-protected, tunnel-routed HTTPS hostnames, not at 127.0.0.1, a raw server IP, or an HTTP URL.
Option B: path-based tunnel rules
If you prefer one admin hostname, add more specific public-hostname rules above the blank fallback:
Hostname: coolify-admin.example.com
Path: terminal/ws
Service: http://127.0.0.1:6002
Hostname: coolify-admin.example.com
Path: app
Service: http://127.0.0.1:6001
Hostname: coolify-admin.example.com
Path: (blank)
Service: http://127.0.0.1:8000
Order matters. Specific websocket paths should be evaluated before the general dashboard route. If the blank dashboard rule catches everything first, the UI may render but realtime features will fail in a confusing way.
4. Fix the asset URL before blaming Access
A half-styled Coolify screen often means the browser is fetching CSS or JavaScript from the wrong origin. Inspect the login HTML and look at the asset URLs. They should be HTTPS URLs under the public admin hostname, not http://127.0.0.1:8000/... or a raw origin address.
curl -sS https://coolify-admin.example.com/ | grep -Eo '/build/assets/[^" ]+' | head
curl -I https://coolify-admin.example.com/build/assets/app.css
If assets point at the wrong base URL, set Coolify's public URL and asset URL to the tunnel hostname, recreate only the Coolify dashboard service, then test again. If Cloudflare Access returns HTML for CSS or JavaScript requests, add narrowly scoped bypass rules for static asset paths only. Do not bypass the whole admin hostname just to make styling work.
5. Verify the replacement path like an operator
Before touching the firewall, verify the whole admin experience:
- Open the admin hostname in a private browser window.
- Confirm Cloudflare Access prompts for identity first.
- Log into Coolify.
- Open a deployment log and confirm live updates stream.
- Open a terminal/session view if you use that feature.
- Fetch
/api/healthlocally from the VPS and through the protected hostname. - Check browser devtools for failed websocket, CSS, JavaScript, or mixed-content requests.
Only after those checks pass should you restrict the raw host ports. This is the same order as the VPS hardening checklist: build the safe path first, prove it, then remove direct exposure.
6. Close the raw admin ports carefully
How you close ports depends on your installation, but the public goal is simple: the dashboard and websocket services should be reachable on loopback or Docker networks, not from the internet. A verification-minded sequence is:
# On the VPS: local health should still work
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/health
# Local listeners should be loopback/private, not public wildcard
ss -tulpn | grep -E ':(8000|6001|6002)\\b'
# Firewall should not allow raw public dashboard/realtime ports
sudo ufw status numbered
Do not close ports 80 and 443 for public apps. Do not block outbound HTTPS that the tunnel connector needs. And do not remove your hosting provider recovery access; that is still the escape hatch if you misconfigure the admin route.
Common failure modes
- Dashboard works, logs do not: websocket routes are missing, ordered after the fallback, or Coolify still points at the old realtime host.
- Access login appears for CSS/JS: static asset paths are being challenged as if they were app pages. Add narrow path bypasses, not a broad public bypass.
- Assets load from localhost: Coolify's app or asset URL is still set to a local address. Update it to the public HTTPS admin hostname.
- Port closed too early: restore access through the provider console or an existing SSH session, re-open locally, and verify the tunnel before trying again.
- Tunnel token leaked: rotate it. Treat tunnel tokens like API keys, even if the tunnel hostname itself is Access-protected.
The short checklist
- Confirm Coolify health locally on
127.0.0.1:8000. - Create a Cloudflare Tunnel connector on the VPS.
- Route the dashboard hostname to
127.0.0.1:8000. - Add Cloudflare Access for the admin hostname.
- Add realtime/terminal websocket hostnames or path rules for
6001and6002. - Set Coolify public URL, asset URL, and realtime hosts to HTTPS public hostnames.
- Verify login, assets, live logs, terminal sessions, and local health.
- Only then restrict raw public access to the admin/realtime ports.
The growth lesson is mundane but useful: a self-hosted stack should be easy to operate without advertising its control plane. Cloudflare Tunnel and Access are not magic security dust, but used in the right order they let a one-person VPS keep public apps public and admin surfaces private. The deployment is not done when the dashboard loads; it is done when realtime works, assets are served from the right host, and the old raw ports are no longer part of the public surface.