How I got Caddy's on-demand TLS working inside Coolify

April 13, 2026
Timelish needs to provision SSL certificates automatically for both subdomains and custom domains. Every business that connects its own domain to its booking page needs HTTPS to just work - no manual cert setup, no waiting, no friction. They type in mybusiness.com, and it works. The same also applies if they use their default subdomain, like mybusiness.timeli.sh .
The feature that makes this possible is Caddy's on_demand_tls. And getting it to play nicely with Coolify took a few hours of digging that I want to save you.



What on-demand TLS actually is

Most reverse proxies handle TLS by issuing certificates at startup - you define your domains upfront, the proxy fetches certs from Let's Encrypt, done. That works great when you know your domains ahead of time.
But Timelish doesn't. Custom domains come in dynamically, at runtime, whenever a user connects one or just creates a new account. There's no way to define them in a config file in advance.
Caddy's on_demand_tls solves this by issuing certificates lazily - the first time a request comes in for a domain, Caddy fetches the cert on the fly. Critically, you can point it at an ask endpoint: a URL Caddy will call to verify whether it should issue a cert for a given hostname before doing so. That's your guard against cert-spamming abuse.
{
on_demand_tls {
ask https://yourdomain.com/check
}
}
The challenge: this has to be a global Caddyfile setting, meaning it lives at the top level before any import statements. It can't be loaded as a dynamic config fragment. And Coolify, which manages its own Caddyfile, will periodically overwrite your changes if you edit the file directly.



The problem with Coolify's Caddy setup

Coolify uses Caddy as an optional proxy (Traefik is the default, but Caddy is well-supported). It manages configuration by writing to /data/coolify/proxy/dynamic/Caddyfile and the main Caddyfile. If you manually edit these files, Coolify will eventually overwrite them - particularly after redeployments or proxy restarts.
After digging through how Coolify passes labels to the Caddy Docker container, I found the answer. You don't edit the Caddyfile directly — you use Docker labels on the Caddy proxy container itself, which Coolify's label-based config system picks up and applies.



The solution

There are two parts.

Part 1 - Add Docker labels to the Caddy proxy container

In Coolify, go to Server → Proxy and open the Docker Compose configuration for the Caddy proxy. Add these labels:
- caddy.on_demand_tls=
- caddy.on_demand_tls.ask=http://domain-checker:5555/check
A few things to note here:
The first label (caddy.on_demand_tls=) with an empty value is intentional - it enables the on_demand_tls block. The second label sets the ask URL to your domain verification service.
Use a container hostname, not a public URL, for the ask endpoint if your checker is running on the same Docker network. Here I'm referencing a service called domain-checker on port 5555. The {{upstreams}} template variable that Coolify uses for other services won't work here - you need the literal container hostname.
Also, if you have Override default request handler enabled in the proxy settings, disable it. It interferes with this setup.

Part 2 - Add a dynamic Caddy configuration

In Coolify, add a new dynamic configuration file to the proxy - call it something like tls_on_demand.caddy. Its contents:
https:// {
tls {
on_demand
}
reverse_proxy web-app:3000
}
The https:// block without a specific hostname is what tells Caddy to apply this config to any HTTPS request where a cert hasn't been issued yet. The on_demand directive inside tls {} connects it to the global setting you configured via labels.
Again, use the Docker container hostname (web-app:3000) rather than localhost or a public address. Everything here runs on the internal Docker network.



Why this works

Coolify's Caddy integration is label-driven. Rather than writing raw Caddyfile syntax, you express configuration intent through Docker labels, and a tool called the Caddy Docker Proxy translates those labels into valid Caddyfile directives. This means your on_demand_tls global settings survive redeployments because they're defined on the container itself, not in a file Coolify manages.
The dynamic config file (tls_on_demand.caddy) goes into Coolify's dynamic config directory, which Coolify does manage - but it won't overwrite files it didn't create. Adding a new file there is safe.



The domain checker service

One thing I haven't covered: the ask endpoint itself. This is a small HTTP service that Caddy calls before issuing any on-demand certificate, passing the requested hostname as a query parameter. Your service should return 200 if the domain is legitimate and mapped to your app, and anything else (typically 403) to reject it.
For Timelish, this is a small service that queries the database to check whether a given domain has been connected to an active account. Without this gate, anyone could point a random domain at your server and trigger cert issuance, which would get you rate-limited by Let's Encrypt quickly.
A minimal implementation looks like this:
// Express example
app.get('/check', async (req, res) => {
const domain = req.query.domain;
const isValid = await db.domains.exists({ hostname: domain, active: true });
res.status(isValid ? 200 : 403).end();
});
Keep it fast. Caddy calls this endpoint synchronously during the TLS handshake, so any latency here directly delays the first connection for a new domain.



What I learned

The instinct when something isn't working in a managed platform is to go around it - edit the files directly, restart things, see what sticks. That approach breaks down the moment the platform does its next reconciliation pass.
The better instinct is to understand how the platform exposes configuration. Coolify's label system is genuinely well thought out. Once I understood that labels were the interface and the Caddyfile was the output, the solution was straightforward.
If you're building something similar - a multi-tenant SaaS where users bring their own domains - I hope this saves you the hours I spent on it. The pattern works well in production.


Timelish is coming soon — an appointment scheduling platform for small businesses. Join the waitlist at timelish.com.
blogposttimeli.shcoolify

Contact me

Email

dmytro@bondarchuk.me
© 2026 Dmytro BondarchukCreated usingTimeli.sh