Repository: caddyserver/caddy

Issue:
Spurious "DNS challenge enabled, but no DNS provider configured" since 2.10.0
Hi, since caddy 2.10 I am getting a spurious `provision tls.issuance.acme: DNS challenge enabled, but no DNS provider configured` error. The minimum Caddyfile is below. Caddy 2.9 doesn't have the same issue.

I am very confused why this is happening. I don't have the DNS challenge configured at all, I am using tlsalpn challenge instead.

Caddyfile:

```
{
    email root@liri.ie
    log default {
        format console
    }
    ocsp_stapling off
}
www.liri.ie {
    reverse_proxy 172.19.0.10:3000 {
        trusted_proxies 172.19.0.0/16
    }
    header Strict-Transport-Security max-age=63072000;includeSubDomains;preload
    tls {
        issuer acme {
            disable_http_challenge
            propagation_timeout 10m
            propagation_delay 2m
        }
    }
}
```

Full error: `caddy-1  | 2025-06-28T19:20:06.941099427Z Error: loading initial config: loading new config: loading http app module: provision http: getting tls app: loading tls app module: provision tls: provisioning automation policy 0: loading TLS automation management module: position 0: loading module 'acme': provision tls.issuance.acme: DNS challenge enabled, but no DNS provider configured`.

docker-compose.yaml

```
    caddy:
      image: caddy:2.10
      restart: unless-stopped
      cap_add:
        - NET_ADMIN
      ports:
        - 2080:80
        - 443:443
        - 443:443/udp
      environment:
        TZ: 'Europe/Dublin'
        PUID: '99'
        PGID: '100'
      labels:
        net.unraid.docker.icon: https://d1q6f0aelx0por.cloudfront.net/product-logos/library-caddy-logo.png
      networks:
        - docker-proxy
        - docker-services
      volumes:
        - /mnt/user/appdata/CaddyV2/Caddyfile.manual:/etc/caddy/Caddyfile
        # this volume is needed to keep the certificates
        # otherwise, new ones will be re-issued upon restart
        - /mnt/user/appdata/CaddyV2/data:/data
```

Hints from issue comments:
@legeana: I solved this. The issue is

```
	propagation_timeout <duration>
	propagation_delay   <duration>
```

These two options make a new entry in the json representation of the config:
```json
{"challenges":
  {"dns":
    {"propagation_delay":120000000000,"propagation_timeout":600000000000},
  ```
That corresponds to `ACMEIssuer.Challenges.DNS != nil`.

This makes the code in modules/caddytls/acmeissuer.go `if iss.Challenges != nil && iss.Challenges.DNS != nil && iss.Challenges.DNS.solver == nil {` enter the if statement, even though the DNS resolver is not explicitly configured.

TL;DR setting propagation_timeout or propagation_delay implicitly configures a DNS resolver.

It would probably make sense to improve the configuration parser in some way, but I am unsure how.@mohammed90: > TL;DR setting propagation_timeout or propagation_delay implicitly configures a DNS resolver.

Correct. These 2 sub-directives are for the DNS challenge. It was an error that we didn't report it as one earlier, and now it's a fixed bug. The [docs](https://caddyserver.com/docs/caddyfile/directives/tls#propagation_timeout) say these sub-directives pertain to the DNS challenge:

> -  **propagation_timeout** is a [duration value](https://caddyserver.com/docs/conventions#durations) that sets the maximum time to wait for the DNS TXT records to appear when using the DNS challenge. Set to -1 to disable propagation checks. Default 2 minutes.
> 
> - **propagation_delay** is a [duration value](https://caddyserver.com/docs/conventions#durations) that sets how long to wait before starting DNS TXT records propagation checks when using the DNS challenge. Default 0 (no wait).

> It would probably make sense to improve the configuration parser in some way, but I am unsure how.

Well, the improvement _is_ in reporting the error rather than quietly ignoring it. I'd say this is maybe a documentation issue, but I'm not sure where the documentation fell short. Any reason you configured them despite not using the DNS challenge?

@legeana: > Correct. These 2 sub-directives are for the DNS challenge. It was an error that we didn't report it as one earlier, and now it's a fixed bug. The [docs](https://caddyserver.com/docs/caddyfile/directives/tls#propagation_timeout) say these sub-directives pertain to the DNS challenge:

That makes total sense once you know about it. The surprise factor was that since these options don't include "dns" in any way I didn't even realise they were related. As you see in the first post it didn't even occur to me to remove these options from the minimal example, I sort of assumed they are related to the tlsalpn configuration without thinking.

And the documentation itself isn't helpful either, for instance https://caddyserver.com/docs/caddyfile/directives/tls#propagation_timeout says "sets the maximum time ... when using the DNS challenge". To me this implies that this option is no-op when DNS challenge is not used. It's pretty common in other projects to configure defaults but not actually enable a feature.

> Well, the improvement is in reporting the error rather than quietly ignoring it.

While I find it odd that a configuration option can implicitly enable a feature, I won't argue that point. I do however think it should be more obvious that it does. I am not sure how to make it more obvious, I would consider the following:
- Add `disable_dns_challenge` similar to `disable_http_challenge` and error out immediately when a user uses any of these options.
- Update documentation for each of these options to clearly state that mentioning it enables DNS challenge. This would be my preference.
- Make a separate entry to say how to disable DNS challenge. I was looking for that in documentation.
- Probably the most complicated: in the `DNS challenge enabled, but no DNS provider configured` error message say why it was enabled.

> Any reason you configured them despite not using the DNS challenge?

I was using DNS challenge in the past, but then moved to tlsalpn and forgot about these options.

Thank you for your time.@francislavoie: It's designed that way because you can have multiple issuers enabled by default (i.e. Let's Encrypt and ZeroSSL, the latter only if you have an email set) so many TLS options are configurable at the top-level to apply to all issuers instead of just one of them (when those are set them propagate down into issuer config). Also DNS config could be set in global options (i.e. `acme_dns`) so it _could_ be valid to have those propagation options set in your site if not in the same site.

> Add `disable_dns_challenge` similar to `disable_http_challenge`

> Make a separate entry to say how to disable DNS challenge. I was looking for that in documentation.

It's not on by default anyway, so `disable_dns_challenge` would be a no-op unless you happened to accidentally set another DNS-related option.

> Update documentation for each of these options to clearly state that mentioning it enables DNS challenge. This would be my preference.

We could do that, yeah.

But it should also be possible to bail during Caddyfile config parsing (instead of during provisioning which happens later) if any DNS options are set without there being a provider.@legeana: > so many TLS options are configurable at the top-level to apply to all issuers instead of just one of them

That makes sense, thank you for providing this context.

> It's not on by default anyway, so disable_dns_challenge would be a no-op unless you happened to accidentally set another DNS-related option.

Yea I suppose taking defaults into account having an option that is a noop most of the time is odd. This idea was meant to make it easier to disable DNS resolvers, and prevent the user from enabling them accidentally. Or at least help the user to figure out which part of the config enables it.

> But it should also be possible to bail during Caddyfile config parsing (instead of during provisioning which happens later) if any DNS options are set without there being a provider.

https://github.com/caddyserver/caddy/pull/7099/files#diff-b334a55e01291007b7bec77848754a7ba67b88dda35811b0279784150cc686f1 is exactly what would have helped me debug the issue, so I greatly appreciate you adding this verification. This clearly highlights the issue with configuration and has all the necessary pointers. Thank you!

Solution patch:
diff --git a/caddyconfig/caddyfile/lexer.go b/caddyconfig/caddyfile/lexer.go
index a3b1fc7d..60dabe43 100644
--- a/caddyconfig/caddyfile/lexer.go
+++ b/caddyconfig/caddyfile/lexer.go
@@ -323,7 +323,8 @@ func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
 
 		// if the padding doesn't match exactly at the start then we can't safely strip
 		if index != 0 {
-			return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, lineText, paddingToStrip)
+			cleanLineText := strings.TrimRight(lineText, "\r\n")
+			return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, cleanLineText, paddingToStrip)
 		}
 
 		// strip, then append the line, with the newline, to the output.
diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go
index fb05d29f..71aa0c2b 100644
--- a/caddyconfig/httpcaddyfile/builtins.go
+++ b/caddyconfig/httpcaddyfile/builtins.go
@@ -130,6 +130,9 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 	var reusePrivateKeys bool
 	var forceAutomate bool
 
+	// Track which DNS challenge options are set
+	var dnsOptionsSet []string
+
 	firstLine := h.RemainingArgs()
 	switch len(firstLine) {
 	case 0:
@@ -350,6 +353,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 			if acmeIssuer.Challenges.DNS == nil {
 				acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
 			}
+			dnsOptionsSet = append(dnsOptionsSet, "resolvers")
 			acmeIssuer.Challenges.DNS.Resolvers = args
 
 		case "propagation_delay":
@@ -371,6 +375,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 			if acmeIssuer.Challenges.DNS == nil {
 				acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
 			}
+			dnsOptionsSet = append(dnsOptionsSet, "propagation_delay")
 			acmeIssuer.Challenges.DNS.PropagationDelay = caddy.Duration(delay)
 
 		case "propagation_timeout":
@@ -398,6 +403,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 			if acmeIssuer.Challenges.DNS == nil {
 				acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
 			}
+			dnsOptionsSet = append(dnsOptionsSet, "propagation_timeout")
 			acmeIssuer.Challenges.DNS.PropagationTimeout = caddy.Duration(timeout)
 
 		case "dns_ttl":
@@ -419,6 +425,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 			if acmeIssuer.Challenges.DNS == nil {
 				acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
 			}
+			dnsOptionsSet = append(dnsOptionsSet, "dns_ttl")
 			acmeIssuer.Challenges.DNS.TTL = caddy.Duration(ttl)
 
 		case "dns_challenge_override_domain":
@@ -435,6 +442,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 			if acmeIssuer.Challenges.DNS == nil {
 				acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
 			}
+			dnsOptionsSet = append(dnsOptionsSet, "dns_challenge_override_domain")
 			acmeIssuer.Challenges.DNS.OverrideDomain = arg[0]
 
 		case "ca_root":
@@ -470,6 +478,18 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
 		}
 	}
 
+	// Validate DNS challenge config: any DNS challenge option except "dns" requires a DNS provider
+	if acmeIssuer != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil {
+		dnsCfg := acmeIssuer.Challenges.DNS
+		providerSet := dnsCfg.ProviderRaw != nil || h.Option("dns") != nil
+		if len(dnsOptionsSet) > 0 && !providerSet {
+			return nil, h.Errf(
+				"setting DNS challenge options [%s] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option)",
+				strings.Join(dnsOptionsSet, ", "),
+			)
+		}
+	}
+
 	// a naked tls directive is not allowed
 	if len(firstLine) == 0 && !hasBlock {
 		return nil, h.ArgErr()



Test patch:
diff --git a/caddytest/integration/caddyfile_adapt/ambiguous_site_definition.caddyfiletest b/caddytest/integration/caddyfile_adapt/ambiguous_site_definition.caddyfiletest
new file mode 100644
index 00000000..bd62d3c4
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/ambiguous_site_definition.caddyfiletest
@@ -0,0 +1,12 @@
+example.com
+handle {
+	respond "one"
+}
+
+example.com
+handle {
+	respond "two"
+}
+----------
+Caddyfile:6: unrecognized directive: example.com
+Did you mean to define a second site? If so, you must use curly braces around each site to separate their configurations.
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/ambiguous_site_definition_duplicate_key.caddyfiletest b/caddytest/integration/caddyfile_adapt/ambiguous_site_definition_duplicate_key.caddyfiletest
new file mode 100644
index 00000000..d182b8ff
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/ambiguous_site_definition_duplicate_key.caddyfiletest
@@ -0,0 +1,9 @@
+:8080 {
+	respond "one"
+}
+
+:8080 {
+	respond "two"
+}
+----------
+ambiguous site definition: :8080
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/directive_as_site_address.caddyfiletest b/caddytest/integration/caddyfile_adapt/directive_as_site_address.caddyfiletest
new file mode 100644
index 00000000..7245fd98
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/directive_as_site_address.caddyfiletest
@@ -0,0 +1,5 @@
+handle
+
+respond "should not work"
+----------
+Caddyfile:1: parsed 'handle' as a site address, but it is a known directive; directives must appear in a site block
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/duplicate_listener_address_global.caddyfiletest b/caddytest/integration/caddyfile_adapt/duplicate_listener_address_global.caddyfiletest
new file mode 100644
index 00000000..5287557b
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/duplicate_listener_address_global.caddyfiletest
@@ -0,0 +1,12 @@
+{
+	servers {
+		srv0 {
+			listen :8080
+		}
+		srv1 {
+			listen :8080
+		}
+	}
+}
+----------
+parsing caddyfile tokens for 'servers': unrecognized servers option 'srv0', at Caddyfile:3
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/heredoc_extra_indentation.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_extra_indentation.caddyfiletest
new file mode 100644
index 00000000..1b71b91f
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/heredoc_extra_indentation.caddyfiletest
@@ -0,0 +1,41 @@
+:80
+
+handle {
+	respond <<END
+        line1
+        line2
+  END
+}
+----------
+{
+	"apps": {
+		"http": {
+			"servers": {
+				"srv0": {
+					"listen": [
+						":80"
+					],
+					"routes": [
+						{
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"body": "      line1\n      line2",
+													"handler": "static_response"
+												}
+											]
+										}
+									]
+								}
+							]
+						}
+					]
+				}
+			}
+		}
+	}
+}
diff --git a/caddytest/integration/caddyfile_adapt/heredoc_incomplete.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_incomplete.caddyfiletest
new file mode 100644
index 00000000..868489c8
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/heredoc_incomplete.caddyfiletest
@@ -0,0 +1,9 @@
+:80
+
+handle {
+    respond <<EOF
+    Hello
+# missing EOF marker
+}
+----------
+mismatched leading whitespace in heredoc <<EOF on line #5 [    Hello], expected whitespace [# missing ] to match the closing marker
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest
new file mode 100644
index 00000000..99c4e4a3
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/heredoc_invalid_marker.caddyfiletest
@@ -0,0 +1,9 @@
+:80
+
+handle {
+    respond <<END!
+    Hello
+    END!
+}
+----------
+heredoc marker on line #4 must contain only alpha-numeric characters, dashes and underscores; got 'END!'
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/heredoc_mismatched_whitespace.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_mismatched_whitespace.caddyfiletest
new file mode 100644
index 00000000..846eb924
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/heredoc_mismatched_whitespace.caddyfiletest
@@ -0,0 +1,10 @@
+:80
+
+handle {
+	respond <<END
+	line1
+	line2
+  END
+}
+----------
+mismatched leading whitespace in heredoc <<END on line #5 [	line1], expected whitespace [  ] to match the closing marker
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/heredoc_missing_marker.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_missing_marker.caddyfiletest
new file mode 100644
index 00000000..a9bb57ca
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/heredoc_missing_marker.caddyfiletest
@@ -0,0 +1,9 @@
+:80
+
+handle {
+    respond << 
+    Hello
+    END
+}
+----------
+parsing caddyfile tokens for 'handle': unrecognized directive: Hello - are you sure your Caddyfile structure (nesting and braces) is correct?, at Caddyfile:7
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/heredoc_too_many_angle_brackets.caddyfiletest b/caddytest/integration/caddyfile_adapt/heredoc_too_many_angle_brackets.caddyfiletest
new file mode 100644
index 00000000..8f0ec784
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/heredoc_too_many_angle_brackets.caddyfiletest
@@ -0,0 +1,9 @@
+:80
+
+handle {
+    respond <<<END
+    Hello
+    END
+}
+----------
+too many '<' for heredoc on line #4; only use two, for example <<END
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/import_cycle.caddyfiletest b/caddytest/integration/caddyfile_adapt/import_cycle.caddyfiletest
new file mode 100644
index 00000000..d1bd7d04
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/import_cycle.caddyfiletest
@@ -0,0 +1,12 @@
+(import1) {
+	import import2
+}
+
+(import2) {
+	import import1
+}
+
+import import1
+
+----------
+a cycle of imports exists between Caddyfile:import2 and Caddyfile:import1
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/invoke_undefined_named_route.caddyfiletest b/caddytest/integration/caddyfile_adapt/invoke_undefined_named_route.caddyfiletest
new file mode 100644
index 00000000..eb72bd7a
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/invoke_undefined_named_route.caddyfiletest
@@ -0,0 +1,5 @@
+example.com {
+	invoke foo
+}
+----------
+cannot invoke named route 'foo', which was not defined
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/matcher_outside_site_block.caddyfiletest b/caddytest/integration/caddyfile_adapt/matcher_outside_site_block.caddyfiletest
new file mode 100644
index 00000000..04590c6c
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/matcher_outside_site_block.caddyfiletest
@@ -0,0 +1,9 @@
+@foo {
+	path /foo
+}
+
+handle {
+	respond "should not work"
+}
+----------
+request matchers may not be defined globally, they must be in a site block; found @foo, at Caddyfile:1
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/site_address_invalid_port.caddyfiletest b/caddytest/integration/caddyfile_adapt/site_address_invalid_port.caddyfiletest
new file mode 100644
index 00000000..3b8e2f59
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/site_address_invalid_port.caddyfiletest
@@ -0,0 +1,7 @@
+:70000
+
+handle {
+	respond "should not work"
+}
+----------
+port 70000 is out of range
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/site_address_negative_port.caddyfiletest b/caddytest/integration/caddyfile_adapt/site_address_negative_port.caddyfiletest
new file mode 100644
index 00000000..b6784986
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/site_address_negative_port.caddyfiletest
@@ -0,0 +1,7 @@
+:-1
+
+handle {
+	respond "should not work"
+}
+----------
+port -1 is out of range
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/site_address_unsupported_scheme.caddyfiletest b/caddytest/integration/caddyfile_adapt/site_address_unsupported_scheme.caddyfiletest
new file mode 100644
index 00000000..8616504e
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/site_address_unsupported_scheme.caddyfiletest
@@ -0,0 +1,7 @@
+foo://example.com
+
+handle {
+	respond "hello"
+}
+----------
+unsupported URL scheme foo://
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/site_address_wss_invalid_port.caddyfiletest b/caddytest/integration/caddyfile_adapt/site_address_wss_invalid_port.caddyfiletest
new file mode 100644
index 00000000..4a906098
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/site_address_wss_invalid_port.caddyfiletest
@@ -0,0 +1,7 @@
+wss://example.com:70000
+
+handle {
+	respond "should not work"
+}
+----------
+port 70000 is out of range
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/site_address_wss_scheme.caddyfiletest b/caddytest/integration/caddyfile_adapt/site_address_wss_scheme.caddyfiletest
new file mode 100644
index 00000000..d1051c07
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/site_address_wss_scheme.caddyfiletest
@@ -0,0 +1,7 @@
+wss://example.com
+
+handle {
+	respond "hello"
+}
+----------
+the scheme wss:// is only supported in browsers; use https:// instead
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/tls_dns_multiple_options_without_provider.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_dns_multiple_options_without_provider.caddyfiletest
new file mode 100644
index 00000000..235089c3
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/tls_dns_multiple_options_without_provider.caddyfiletest
@@ -0,0 +1,9 @@
+localhost
+
+tls {
+	propagation_delay 10s
+	dns_ttl 5m
+}
+
+----------
+parsing caddyfile tokens for 'tls': setting DNS challenge options [propagation_delay, dns_ttl] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option), at Caddyfile:6
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/tls_dns_propagation_timeout_without_provider.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_dns_propagation_timeout_without_provider.caddyfiletest
new file mode 100644
index 00000000..48602135
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/tls_dns_propagation_timeout_without_provider.caddyfiletest
@@ -0,0 +1,7 @@
+:443 {
+	tls {
+		propagation_timeout 30s
+	}
+}
+----------
+parsing caddyfile tokens for 'tls': setting DNS challenge options [propagation_timeout] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option), at Caddyfile:4
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/tls_dns_propagation_without_provider.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_dns_propagation_without_provider.caddyfiletest
new file mode 100644
index 00000000..6ab6e323
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/tls_dns_propagation_without_provider.caddyfiletest
@@ -0,0 +1,7 @@
+:443 {
+	tls {
+		propagation_delay 30s
+	}
+}
+----------
+parsing caddyfile tokens for 'tls': setting DNS challenge options [propagation_delay] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option), at Caddyfile:4
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/tls_dns_ttl.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_dns_ttl.caddyfiletest
index c452bf79..6d7c007b 100644
--- a/caddytest/integration/caddyfile_adapt/tls_dns_ttl.caddyfiletest
+++ b/caddytest/integration/caddyfile_adapt/tls_dns_ttl.caddyfiletest
@@ -2,6 +2,7 @@ localhost
 
 respond "hello from localhost"
 tls {
+	dns mock
 	dns_ttl 5m10s
 }
 ----------
@@ -54,6 +55,9 @@ tls {
 							{
 								"challenges": {
 									"dns": {
+										"provider": {
+											"name": "mock"
+										},
 										"ttl": 310000000000
 									}
 								},
diff --git a/caddytest/integration/caddyfile_adapt/tls_propagation_options.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_propagation_options.caddyfiletest
index 43ec9774..f45959b5 100644
--- a/caddytest/integration/caddyfile_adapt/tls_propagation_options.caddyfiletest
+++ b/caddytest/integration/caddyfile_adapt/tls_propagation_options.caddyfiletest
@@ -2,6 +2,7 @@ localhost
 
 respond "hello from localhost"
 tls {
+	dns mock
 	propagation_delay 5m10s
 	propagation_timeout 10m20s
 }
@@ -56,7 +57,10 @@ tls {
 								"challenges": {
 									"dns": {
 										"propagation_delay": 310000000000,
-										"propagation_timeout": 620000000000
+										"propagation_timeout": 620000000000,
+										"provider": {
+											"name": "mock"
+										}
 									}
 								},
 								"module": "acme"
diff --git a/caddytest/integration/caddyfile_adapt_test.go b/caddytest/integration/caddyfile_adapt_test.go
index 674036e1..9bc6af4b 100644
--- a/caddytest/integration/caddyfile_adapt_test.go
+++ b/caddytest/integration/caddyfile_adapt_test.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/caddyserver/caddy/v2/caddyconfig"
 	"github.com/caddyserver/caddy/v2/caddytest"
 	_ "github.com/caddyserver/caddy/v2/internal/testmocks"
 )
@@ -27,30 +28,48 @@ func TestCaddyfileAdaptToJSON(t *testing.T) {
 		if f.IsDir() {
 			continue
 		}
-
-		// read the test file
 		filename := f.Name()
-		data, err := os.ReadFile("./caddyfile_adapt/" + filename)
-		if err != nil {
-			t.Errorf("failed to read %s dir: %s", filename, err)
-		}
 
-		// split the Caddyfile (first) and JSON (second) parts
-		// (append newline to Caddyfile to match formatter expectations)
-		parts := strings.Split(string(data), "----------")
-		caddyfile, json := strings.TrimSpace(parts[0])+"\n", strings.TrimSpace(parts[1])
+		// run each file as a subtest, so that we can see which one fails more easily
+		t.Run(filename, func(t *testing.T) {
+			// read the test file
+			data, err := os.ReadFile("./caddyfile_adapt/" + filename)
+			if err != nil {
+				t.Errorf("failed to read %s dir: %s", filename, err)
+			}
 
-		// replace windows newlines in the json with unix newlines
-		json = winNewlines.ReplaceAllString(json, "\n")
+			// split the Caddyfile (first) and JSON (second) parts
+			// (append newline to Caddyfile to match formatter expectations)
+			parts := strings.Split(string(data), "----------")
+			caddyfile, expected := strings.TrimSpace(parts[0])+"\n", strings.TrimSpace(parts[1])
 
-		// replace os-specific default path for file_server's hide field
-		replacePath, _ := jsonMod.Marshal(fmt.Sprint(".", string(filepath.Separator), "Caddyfile"))
-		json = strings.ReplaceAll(json, `"./Caddyfile"`, string(replacePath))
+			// replace windows newlines in the json with unix newlines
+			expected = winNewlines.ReplaceAllString(expected, "\n")
 
-		// run the test
-		ok := caddytest.CompareAdapt(t, filename, caddyfile, "caddyfile", json)
-		if !ok {
-			t.Errorf("failed to adapt %s", filename)
-		}
+			// replace os-specific default path for file_server's hide field
+			replacePath, _ := jsonMod.Marshal(fmt.Sprint(".", string(filepath.Separator), "Caddyfile"))
+			expected = strings.ReplaceAll(expected, `"./Caddyfile"`, string(replacePath))
+
+			// if the expected output is JSON, compare it
+			if len(expected) > 0 && expected[0] == '{' {
+				ok := caddytest.CompareAdapt(t, filename, caddyfile, "caddyfile", expected)
+				if !ok {
+					t.Errorf("failed to adapt %s", filename)
+				}
+				return
+			}
+
+			// otherwise, adapt the Caddyfile and check for errors
+			cfgAdapter := caddyconfig.GetAdapter("caddyfile")
+			_, _, err = cfgAdapter.Adapt([]byte(caddyfile), nil)
+			if err == nil {
+				t.Errorf("expected error for %s but got none", filename)
+			} else {
+				normalizedErr := winNewlines.ReplaceAllString(err.Error(), "\n")
+				if !strings.Contains(normalizedErr, expected) {
+					t.Errorf("expected error for %s to contain:\n%s\nbut got:\n%s", filename, expected, normalizedErr)
+				}
+			}
+		})
 	}
 }