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 → AppIn the tunnel config, you map each hostname to a specific port:
| Hostname | Service |
|---|---|
| mysaas.dev | http://localhost:3000 |
| docs.mysaas.dev | http://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:
| Type | Name | Target |
|---|---|---|
| Tunnel/CNAME | mysaas.dev | boring-labs-s1 |
| Tunnel/CNAME | docs | boring-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.
Tunnel to Traefik to App (Recommended)
All traffic goes through one port to Traefik, which routes based on hostname.
User → Cloudflare (HTTPS) → Tunnel → Traefik (:80) → Routes by hostname → ContainerThe tunnel config is much simpler:
| Hostname | Service |
|---|---|
| mysaas.dev | http://localhost:80 |
| *.mysaas.dev | http://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:
| Type | Name | Target | Note |
|---|---|---|---|
| Tunnel/CNAME | mysaas.dev | boring-labs-s1 | Auto-created |
| CNAME | * | tunnel-id.cfargotunnel.com | Manual, 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.devmatches docs.mysaas.dev, quality.mysaas.dev*.mysaas.devdoes 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:
- Coolify's GitHub App receives the webhook
- It builds and deploys the PR branch as an isolated container
- It posts a comment on the PR with the preview URL
- Each push to the PR triggers a redeploy
- Clean up manually when the PR is merged or closed
Each preview gets its own Docker network for isolation.
The Architecture
| Environment | Domain | Purpose |
|---|---|---|
| Production | mysaas.dev | Live site |
| Quality/Staging | quality.mysaas.dev | QA testing |
| Preview PR #42 | pr-42.mysaas.dev | Feature 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 Auth | Cloudflare Access | |
|---|---|---|
| Setup | Per-app labels, password hashing | One rule covers wildcard |
| Preview deploys | Configure per preview | Wildcard covers all |
| Security | Credentials in headers | Zero Trust, OTP/SSO |
| UX | Browser popup | Clean login page |
| Cost | Free | Free (up to 50 users) |
Setup Steps
- Go to Cloudflare Zero Trust, then Access, then Applications
- Add a Self-hosted Application
- Set domain to *.mysaas.dev (covers quality + all previews)
- Create a policy: allow specific emails or use One-Time PIN (OTP), no IdP needed
- Optionally add a bypass rule for mysaas.dev if the main site should be public
The Complete Architecture
DNS Records
| Type | Name | Target | Proxy |
|---|---|---|---|
| CNAME/Tunnel | @ | boring-labs-s1 | Proxied |
| CNAME | * | tunnel-id.cfargotunnel.com | Proxied |
Tunnel Routes
| Hostname | Service |
|---|---|
| mysaas.dev | http://localhost:80 |
| *.mysaas.dev | http://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 containerGotchas
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.