← Back home

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:

  1. Open the admin hostname in a private browser window.
  2. Confirm Cloudflare Access prompts for identity first.
  3. Log into Coolify.
  4. Open a deployment log and confirm live updates stream.
  5. Open a terminal/session view if you use that feature.
  6. Fetch /api/health locally from the VPS and through the protected hostname.
  7. 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

The short checklist

  1. Confirm Coolify health locally on 127.0.0.1:8000.
  2. Create a Cloudflare Tunnel connector on the VPS.
  3. Route the dashboard hostname to 127.0.0.1:8000.
  4. Add Cloudflare Access for the admin hostname.
  5. Add realtime/terminal websocket hostnames or path rules for 6001 and 6002.
  6. Set Coolify public URL, asset URL, and realtime hosts to HTTPS public hostnames.
  7. Verify login, assets, live logs, terminal sessions, and local health.
  8. 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.