Coolify + Cloudflare Tunnel: Traefik Routing and Preview Deployments

February 23, 2026

The Two Routing Approaches

When you pair Coolify with Cloudflare Tunnel, there are two ways to route traffic to your apps. I started with the simple one and eventually switched to Traefik. Here's why.

Direct Tunnel to App (Simple, No Traefik)

Each app gets its own tunnel route pointing directly to its port on the host.

User → Cloudflare (HTTPS) → Tunnel → localhost:PORT → App

In the tunnel config, you map each hostname to a specific port:

HostnameService
mysaas.devhttp://localhost:3000
docs.mysaas.devhttp://localhost:3001

In Coolify, you set Ports Exposes to 3000 and Port Mappings to 3000:3000 to publish the container port to the host.

The DNS records are auto-created by the tunnel:

TypeNameTarget
Tunnel/CNAMEmysaas.devboring-labs-s1
Tunnel/CNAMEdocsboring-labs-s1

This works fine for a couple of apps. It's simple, one route per app, easy to understand. But it falls apart fast. Every new app needs a new tunnel route AND a unique host port. You're managing port assignments manually, ports are exposed on the host, and there's no dynamic routing. That means no preview deployments.

All traffic goes through one port to Traefik, which routes based on hostname.

User → Cloudflare (HTTPS) → Tunnel → Traefik (:80) → Routes by hostname → Container

The tunnel config is much simpler:

HostnameService
mysaas.devhttp://localhost:80
*.mysaas.devhttp://localhost:80

In Coolify, you only set Ports Exposes to 3000 (the internal container port). No port mappings needed. Traefik reaches the container through the Docker network. Set the domain to http://mysaas.dev or http://docs.mysaas.dev.

For DNS, you need two records:

TypeNameTargetNote
Tunnel/CNAMEmysaas.devboring-labs-s1Auto-created
CNAME*tunnel-id.cfargotunnel.comManual, wildcards are not auto-created

The benefits are significant. One tunnel route handles everything via wildcard. Preview deployments work because Traefik routes dynamically. You get middleware for free: basic auth, gzip, headers, all via container labels. No port management, no ports exposed on the host, and all apps stay visible in Coolify.

The only downside is one extra layer, but the latency impact is minimal.

Key Concepts

How Traefik Routing Works

When Coolify deploys a container, it automatically adds Docker labels like:

traefik.http.routers.my-app.rule=Host(`docs.mysaas.dev`)

Traefik watches for these labels and creates routing rules on the fly. No manual config needed. Deploy a new app in Coolify, set the domain, and Traefik handles the rest.

DNS Wildcard Depth

Wildcards only match one level deep:

  • *.mysaas.dev matches docs.mysaas.dev, quality.mysaas.dev
  • *.mysaas.dev does NOT match 42.quality.mysaas.dev (two levels deep)

This matters for preview deployments. Use a flat structure like pr-42.mysaas.dev instead of 42.quality.mysaas.dev. It stays at one level and is covered by the free Cloudflare SSL certificate.

Cloudflare SSL Certificate Limitation

Free Cloudflare Universal SSL covers:

  • mysaas.dev
  • *.mysaas.dev
  • But NOT *.quality.mysaas.dev (needs Advanced Certificate Manager at $10/month)

The solution: use flat subdomain structure (pr-42.mysaas.dev) to stay within the free SSL coverage.

HTTP vs HTTPS in Coolify

When using Cloudflare Tunnel, set domains as HTTP in Coolify (e.g., http://mysaas.dev), not HTTPS. Cloudflare handles TLS termination. Using HTTPS in Coolify causes TOO_MANY_REDIRECTS errors unless you explicitly configure Full TLS mode.

Preview Deployments for Next.js

Prerequisites

  • GitHub App integration in Coolify (not plain webhook)
  • Wildcard DNS record (* pointing to the tunnel)
  • Wildcard tunnel route (*.mysaas.dev pointing to http://localhost:80)

Setup Steps

First, create your quality/staging app in Coolify. Set the source to your GitHub repo, the branch to quality (or staging, develop), and the domain to http://quality.mysaas.dev.

Then enable Preview Deployments. Go to the app, open Advanced settings, toggle Preview Deployments ON, and set the preview URL template to pr-{{pr_id}}.mysaas.dev.

Here's what happens when you open a PR targeting the quality branch:

  1. Coolify's GitHub App receives the webhook
  2. It builds and deploys the PR branch as an isolated container
  3. It posts a comment on the PR with the preview URL
  4. Each push to the PR triggers a redeploy
  5. Clean up manually when the PR is merged or closed

Each preview gets its own Docker network for isolation.

The Architecture

EnvironmentDomainPurpose
Productionmysaas.devLive site
Quality/Stagingquality.mysaas.devQA testing
Preview PR #42pr-42.mysaas.devFeature testing

Next.js Caveat

COOLIFY_FQDN can be undefined during static generation in preview builds. This is a known Coolify issue.

Next.js has two phases: build time (static generation via next build) and runtime (server handling requests). COOLIFY_FQDN is a runtime environment variable injected by Coolify into the container, but static generation runs during the build step, before the container is fully running with all its environment. So any code that uses COOLIFY_FQDN at build time gets undefined:

// Runs at build time for static pages
const baseUrl = process.env.COOLIFY_FQDN || 'http://localhost:3000';
 
// Used for absolute URLs, OG images, sitemaps, etc.
export const metadata = {
  metadataBase: new URL(baseUrl), // undefined in preview builds
};

For the main app, you can hardcode the domain (mysaas.dev). But preview deployments get dynamic domains (pr-42.mysaas.dev, pr-43.mysaas.dev). You can't hardcode them, so you'd want to rely on COOLIFY_FQDN, which is exactly when it's unavailable.

Workarounds:

  • Set a build-time env var explicitly in Coolify's preview settings: NEXT_PUBLIC_SITE_URL=http://pr-\{\{pr_id\}\}.mysaas.dev
  • Or avoid using the domain at build time. Use relative URLs where possible and defer absolute URL construction to runtime (middleware, API routes, headers() in Server Components)

Protecting Environments with Cloudflare Access

Why Cloudflare Access Over Traefik Basic Auth

Traefik Basic AuthCloudflare Access
SetupPer-app labels, password hashingOne rule covers wildcard
Preview deploysConfigure per previewWildcard covers all
SecurityCredentials in headersZero Trust, OTP/SSO
UXBrowser popupClean login page
CostFreeFree (up to 50 users)

Setup Steps

  1. Go to Cloudflare Zero Trust, then Access, then Applications
  2. Add a Self-hosted Application
  3. Set domain to *.mysaas.dev (covers quality + all previews)
  4. Create a policy: allow specific emails or use One-Time PIN (OTP), no IdP needed
  5. Optionally add a bypass rule for mysaas.dev if the main site should be public

The Complete Architecture

DNS Records

TypeNameTargetProxy
CNAME/Tunnel@boring-labs-s1Proxied
CNAME*tunnel-id.cfargotunnel.comProxied

Tunnel Routes

HostnameService
mysaas.devhttp://localhost:80
*.mysaas.devhttp://localhost:80

Traffic Flow

mysaas.dev          → Cloudflare → Tunnel → Traefik → Main app container
docs.mysaas.dev     → Cloudflare → Tunnel → Traefik → Docs container
quality.mysaas.dev  → Cloudflare Access → Tunnel → Traefik → Quality container
pr-42.mysaas.dev    → Cloudflare Access → Tunnel → Traefik → Preview PR #42 container

Gotchas

A few things that weren't obvious:

  • Wildcard DNS records are NOT auto-created by Cloudflare Tunnel. Add them manually.
  • Wildcard SSL only covers one level. Use pr-42.mysaas.dev, not 42.quality.mysaas.dev.
  • Use HTTP in Coolify when behind Cloudflare Tunnel. Cloudflare handles HTTPS.
  • Remove port mappings when switching to Traefik. Only Ports Exposes is needed.
  • Redeploy containers after changing domain/port config in Coolify.
  • Cloudflare Tunnel and localhost. If the tunnel runs in Docker (not host mode), use coolify-proxy:80 instead of localhost:80.

References