ECH config generated but not published to Cloudflare DNS (v2.11 Beta)

Hi everyone,

I am testing the new native ECH (Encrypted Client Hello) support in Caddy v2.11 Beta using Cloudflare as the DNS provider.

1. The problem I’m having:

Caddy successfully generates ECH keys (generated new ECH config), but it never attempts to publish the HTTPS record to Cloudflare DNS. The logs show certificate maintenance and ACME challenges working perfectly, but the “publishing ECH config” step is missing entirely.

2. Error messages and/or full log output:

https://pastebin.com/raw/ZXPrW1hX

3. Caddy version:

Caddy v2.11.0-beta.2 (running via Docker) with dns.providers.cloudflare module.

4. How I installed and ran Caddy:

In docker: docker compose build --pull caddy && docker compose up -d && docker system prune -f

a. System environment:

Arch Linux, x86-64, systemd, docker
DNS Provider: Cloudflare.

c. Service/unit/compose file:

compose.yaml:

services:
  caddy:
    build: .
    image: caddy-with-cloudflare
    container_name: caddy
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp" # Required for HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
      - /srv/docker/caddy/log:/var/log/caddy
volumes:
  caddy_data:
  caddy_config:

Dockerfile:

FROM caddy:builder-alpine AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare

FROM caddy:2.11-alpine

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

d. My complete Caddy config:

{
	email caddy.cert@acme.camis.ovh
	ech ...
	cert_issuer acme {
		dir https://acme-v02.api.letsencrypt.org/directory
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	}

	log {
		output file /var/log/caddy/system.log {
			roll_size 20mb
			roll_keep 5
			roll_keep_for 6h
		}
		format console
		level debug
	}
}

# Snippet: Settings

(settings) {
	header {
		-Server
		X-Robots-Tag "noindex, nofollow"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
	}

	tls {
		protocols tls1.3
		curves x25519
		issuer acme {
			dir https://acme-v02.api.letsencrypt.org/directory
			profile shortlived
			dns cloudflare {env.CLOUDFLARE_API_TOKEN}
		}
	}
}

# Snippet: Restrict access to Local Network only

(local_only) {
	@not_local not remote_ip 10.0.0.0/20 127.0.0.1
	respond @not_local "Forbidden" 403
}

# --- Domain Blocks ---

... {
	import settings
	import local_only
	reverse_proxy https://10.0.10.10:8006 {
		transport http {
			tls_insecure_skip_verify
		}
	}
}

... {
	import settings
	import local_only
	reverse_proxy https://10.0.10.12:9443 {
		transport http {
			tls_insecure_skip_verify
		}
	}
}

... {
	import settings
	import local_only
	reverse_proxy http://10.0.10.11:80
}

... {
	import settings
	import local_only
	reverse_proxy http://10.0.10.14:8080
}

... {
	import settings
	import local_only
	reverse_proxy http://10.0.10.17:8123
}

... {
	import settings
	reverse_proxy http://10.0.10.15:80
}

... {
	import settings
	handle_path /osmand/* {
		reverse_proxy http://10.0.10.22:5055
	}
	handle {
		reverse_proxy http://10.0.10.22:80
	}
}

... {
	import settings
	reverse_proxy http://10.0.10.11:8000
}

... {
	import settings
	respond "Welcome!" 200
}

... {
	import settings
	respond "Welcome!" 200
}

... {
	import settings
	# Relay (WebSocket)
	reverse_proxy /relay* netbird-relay:80

	# Signal WebSocket
	reverse_proxy /ws-proxy/signal* netbird-signal:80

	# Signal gRPC (h2c for plaintext HTTP/2)
	reverse_proxy /signalexchange.SignalExchange/* h2c://netbird-signal:10000

	# Management API
	reverse_proxy /api/* netbird-management:80

	# Management WebSocket
	reverse_proxy /ws-proxy/management* netbird-management:80

	# Management gRPC
	reverse_proxy /management.ManagementService/* h2c://netbird-management:80

	# Embedded IdP OAuth2
	reverse_proxy /oauth2/* netbird-management:80

	# Dashboard (catch-all)
	reverse_proxy /* netbird-dashboard:80
}

As you can see, the config is generated, and ACME DNS-01 challenge works fine (certs are obtained), so the API token is valid. However, there is no log entry regarding the publication of the HTTPS record for ECH.

Logs (Debug Level):**

https://pastebin.com/raw/ZXPrW1hX

What I have verified/attempted:

Cloudflare “Grey Cloud”: The A record for ech1.camis.ovh exists and is set to DNS Only (Grey Cloud), not Proxied.

API Token Permissions: The token works for acme_dns (Caddy successfully creates and removes TXT records for DNS-01 challenges). The token has Zone:DNS:Edit permissions.

Clean State: I have wiped the caddy_data volume to force a fresh generation of keys and certificates.

Verification: Running dig @1.1.1.1 HTTPS ech1.camis.ovh returns no ANSWER section.

My Question:

Does the global acme_dns directive apply to the ech module for publishing DNS records, or does the ech directive require its own explicit DNS provider configuration block?.
It seems like Caddy knows how to use Cloudflare for ACME but doesn’t use it to publish the ECH keys.

Question about ECH and Wildcard Certificates:

I have one architectural question regarding Caddy’s ECH implementation:
Does ECH work correctly when using Wildcard certificates for the hosted sites?.

Currently, I define each subdomain explicitly in my Caddyfile (e.g., app1.example.com, app2.example.com), so Caddy manages separate certificates for them.

If I were to switch to a Wildcard certificate setup (e.g., *.example.com) while keeping a dedicated ECH public name (e.g., ech.example.com):

Will Caddy be able to correctly unwrap the ECH ClientHello and serve the wildcard certificate for the inner SNI?

Or does the current ECH implementation require explicit domain blocks (and individual certificates) to map the keys correctly?

Thanks for any help!

Per the docs, you need to configure the dns global option as such:

{
	dns <provider config...>
	ech example.com
}
1 Like

Thank you for your reply. A few days after posting, I managed to set it up after reading the documentation. I previously used AI to generate the configuration…

I still have a question: can the ech.XXXX.ovh also use short-lived profiles? Currently, I get the normal 90 days, but I would like to use short-lived profiles for everything.

Something like this, Caddy successfully publish all HTTPS record:

{
	email caddy.cert@XXXX.ovh
	dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	ech ech.XXXX.ovh
}

# --- Snippets ---

(settings) {
	encode zstd gzip
	header {
		-Server
		-X-Powered-By
		X-Robots-Tag "noindex, nofollow"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
		Permissions-Policy "interest-cohort=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), payment=()"
	}

	tls {
		protocols tls1.3
		curves x25519
		issuer acme {
			dir https://acme-v02.api.letsencrypt.org/directory
			profile shortlived
			dns cloudflare {env.CLOUDFLARE_API_TOKEN}
		}
	}
}

(local_only) {
	@not_local not remote_ip 10.0.0.0/20 127.0.0.1
	respond @not_local "Forbidden" 403
}

(logging) {
	log {
		output file /var/log/caddy/access.log {
			roll_size 100mb
			roll_keep 2
			roll_keep_for 48h
		}
		format json
	}
}

# --- Domain Blocks ---

1 Like