1. The problem I’m having:
I’m using a cloudflare api token (I’ve tried both user and account api keys) and caddy is unable to obtain a certificate.
2. Error messages and/or full log output:
{"level":"info","ts":1759808468.1592867,"msg":"trying to solve challenge","identifier":"squarephone.biz","challenge_type":"dns-01","ca":"https://acme-staging-v02.api.letsencrypt.org/directory"}
{"level":"info","ts":1759808468.3930035,"msg":"trying to solve challenge","identifier":"video.squarephone.biz","challenge_type":"dns-01","ca":"https://acme-staging-v02.api.letsencrypt.org/directory"}
{"level":"error","ts":1759808469.245388,"msg":"cleaning up solver","identifier":"squarephone.biz","challenge_type":"dns-01","error":"no memory of presenting a DNS record for \"_acme-challenge.squarephone.biz\" (usually OK if presenting also failed)","stacktrace":"github.com/mholt/acmez/v3.(*Client).solveChallenges.func1\n\tgithub.com/mholt/acmez/v3@v3.1.2/client.go:318\ngithub.com/mholt/acmez/v3.(*Client).solveChallenges\n\tgithub.com/mholt/acmez/v3@v3.1.2/client.go:363\ngithub.com/mholt/acmez/v3.(*Client).ObtainCertificate\n\tgithub.com/mholt/acmez/v3@v3.1.2/client.go:136\ngithub.com/caddyserver/certmagic.(*ACMEIssuer).doIssue\n\tgithub.com/caddyserver/certmagic@v0.23.0/acmeissuer.go:489\ngithub.com/caddyserver/certmagic.(*ACMEIssuer).Issue\n\tgithub.com/caddyserver/certmagic@v0.23.0/acmeissuer.go:382\ngithub.com/caddyserver/caddy/v2/modules/caddytls.(*ACMEIssuer).Issue\n\tgithub.com/caddyserver/caddy/v2@v2.10.0/modules/caddytls/acmeissuer.go:288\ngithub.com/caddyserver/certmagic.(*Config).obtainCert.func2\n\tgithub.com/caddyserver/certmagic@v0.23.0/config.go:626\ngithub.com/caddyserver/certmagic.doWithRetry\n\tgithub.com/caddyserver/certmagic@v0.23.0/async.go:104\ngithub.com/caddyserver/certmagic.(*Config).obtainCert\n\tgithub.com/caddyserver/certmagic@v0.23.0/config.go:700\ngithub.com/caddyserver/certmagic.(*Config).ObtainCertAsync\n\tgithub.com/caddyserver/certmagic@v0.23.0/config.go:505\ngithub.com/caddyserver/certmagic.(*Config).manageOne.func1\n\tgithub.com/caddyserver/certmagic@v0.23.0/config.go:415\ngithub.com/caddyserver/certmagic.(*jobManager).worker\n\tgithub.com/caddyserver/certmagic@v0.23.0/async.go:73"}
{"level":"error","ts":1759808469.4680657,"logger":"tls.obtain","msg":"could not get certificate from issuer","identifier":"squarephone.biz","issuer":"acme-staging-v02.api.letsencrypt.org-directory","error":"[squarephone.biz] solving challenges: presenting for challenge: adding temporary record for zone \"biz.\": expected 1 zone, got 0 for biz. (order=https://acme-staging-v02.api.letsencrypt.org/acme/order/233051223/27712949623) (ca=https://acme-staging-v02.api.letsencrypt.org/directory)"}
{"level":"error","ts":1759808469.4681072,"logger":"tls.obtain","msg":"will retry","error":"[squarephone.biz] Obtain: [squarephone.biz] solving challenges: presenting for challenge: adding temporary record for zone \"biz.\": expected 1 zone, got 0 for biz. (order=https://acme-staging-v02.api.letsencrypt.org/acme/order/233051223/27712949623) (ca=https://acme-staging-v02.api.letsencrypt.org/directory)","attempt":5,"retrying_in":600,"elapsed":608.643667406,"max_duration":2592000}
3. Caddy version:
docker exec -it caddy caddy --versionv2.10.0 h1:fonubSaQKF1YANl8TXqGcn4IbIRUDdfAkpcsfI/vX5U=
4. How I installed and ran Caddy:
Its running in a docker container, using docker-compose.
I’ve confirmed that the token is correctly named with the correct key in the container.
From within the container you can see the following env:
SHLVL=1
HOME=/root
OLDPWD=/opt/xxxx/caddy/sites
CADDY_VERSION=v2.10.0
ACME_AGREE=true
ACTIVE_SITE=sites/dev/*.caddy
TERM=xterm
ACME_URL=https://acme-staging-v02.api.letsencrypt.org/directory
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
XDG_CONFIG_HOME=/opt/xxxx/caddy/config
XDG_DATA_HOME=/opt/xxxx/caddy/data
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXX
PWD=/opt/xxxx/caddy
a. System environment:
docker see the above docker file.
b. Command:
The caddy command is run by the above docker container.
c. Service/unit/compose file:
caddy:
container_name: caddy
image: xxx/xxx-caddy:${XXX_VERSION}
restart: always
network_mode: "host"
cap_add:
- NET_ADMIN
environment:
ACME_AGREE: "true"
ACME_URL: ${ACME_URL} # staging or production
EMAIL: ${AUTH_PROVIDER_EMAIL_ADDRESS}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
ACTIVE_SITE: ${ACTIVE_SITE}
ports:
- 80:80
- 443:443
- 443:443/udp
volumes:
# Persist certificates
- caddy:/caddy
- filestore:/opt/xxx/filestore
#- /opt/xxx/caddy:/opt/xxx/caddy
# Logs
- /tmp/caddy:/var/log/caddy
logging:
driver: "journald"
# we build our own caddy file as we need the cloudflare module.
FROM caddy:2.10.0-builder AS builder
# https://caddyserver.com/docs/modules/dns.providers.cloudflare
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/WeidiDeng/caddy-cloudflare-ip \
--with github.com/corazawaf/coraza-caddy/v2
FROM caddy:2.10.0
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
# Set custom directories for data and config
ENV XDG_CONFIG_HOME=/opt/xxxx/caddy/config
ENV XDG_DATA_HOME=/opt/xxx/caddy/data
# RUN mkdir -p /caddy
# coraza WAF configuation directory
RUN mkdir -p /opt/xxxx/caddy/coraza
COPY config/caddy/ /opt/xxxx/caddy/
# waf configuration files.
COPY config/coraza /opt/xxxx/caddy/coraza
# so we know where to put any custom waf commands.
RUN touch /opt/xxxx/caddy/coraza/waf-directives.conf
CMD ["caddy", "run", "--config", "/opt/xxx/caddy/Caddyfile", "--adapter", "caddyfile"]
d. My complete Caddy config:
The caddy config is split over multiple files:
Caddyfile
# Dev: squarephone.biz (same app/WAF/heartbeat as onepub.dev)
squarephone.biz {
# Typically keep HSTS off for dev to avoid stickiness; remove '-' to enable.
header {
-Strict-Transport-Security
}
import cloudflare-tls
import common-security
import common-app-routes
import common-mailhog
}
/opt/XXXX/caddy/sites/dev # cd ..
/opt/XXXX/caddy/sites # cd ..
/opt/XXXX/caddy # cat Caddyfile
{
# ===== Global =====
email {$EMAIL}
acme_ca {$ACME_URL:"https://acme-staging-v02.api.letsencrypt.org/directory"}
servers {
trusted_proxies cloudflare {
interval 12h
timeout 15s
}
}
order coraza_waf first
}
import common.caddy
# Only these will be loaded; set ACTIVE_IMPORTS to a space-separated list of site files.
# e.g.
# ACTIVE_SITE="sites/production/*.caddy"
# ACTIVE_SITE="sites/beta/*.caddy"
# ACTIVE_SITE="sites/dev/*.caddy"
import "{$ACTIVE_SITE}"
common.caddy
# ===== Reusable TLS (single CF token for all zones) =====
(cloudflare-tls) {
tls {
dns cloudflare {
api_token {$CLOUDFLARE_API_TOKEN}
}
}
}
# ===== Reusable snippets =====
(common-security) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
}
@block_ai header_regexp User-Agent "(?i)(GPTBot|ChatGPT|Google-Extended|Claude-Web|Anthropic|Amazonbot|FacebookBot|cohere-ai|Bytespider|YouBot)"
respond @block_ai 403
@block_php path *.php
abort @block_php
@multiple_slashes path_regexp multipleSlashes ^(.*)//+(.*)$
redir @multiple_slashes {scheme}://{host}{re.multipleSlashes.1}/{re.multipleSlashes.2}{query} permanent
encode gzip
}
(common-waf) {
coraza_waf {
load_owasp_crs
directives `
Include @coraza.conf-recommended
Include @crs-setup.conf.example
Include @owasp_crs/*.conf
Include /opt/xxxx/caddy/coraza/waf-directives.conf
`
}
}
(common-proxy8080) {
reverse_proxy 127.0.0.1:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_timeout 300s
}
header_up Connection {header.Connection}
header_up Upgrade {header.Upgrade}
}
}
(common-app-routes) {
# we need to bypass WAF for some of our internal
# end points that look a little odd to WAF.
# WebSocket upgrades (keep first)
@ws {
header_regexp Upgrade (?i)websocket
header_regexp Connection (?i)\bupgrade\b
}
handle @ws {
import common-proxy8080
}
# UIDL calls like "/?v-r=uidl&v-uiId=..."
@vaadin_uidl {
path /
query v-r=uidl
query v-uiId=*
}
handle @vaadin_uidl {
import common-proxy8080
}
# Vaadin Push long-poll/stream: "/VAADIN/push?v-r=push&v-uiId=...&v-pushId=..."
@vaadin_push {
path /VAADIN/push
query v-r=push
query v-uiId=*
query v-pushId=*
}
handle @vaadin_push {
import common-proxy8080
}
# Your endpoint that currently sends text/plain
@visibility path /api/visibility
handle @visibility {
import common-proxy8080
}
# Heartbeat (already bypassed, keep consistent)
@heartbeat path /api/clientheartbeat
handle @heartbeat {
import common-proxy8080
}
# Everything else under WAF
handle {
import common-waf
import common-proxy8080
}
# API error mapping
handle_errors {
@api path /api/*
handle @api {
root * /etc/caddy/json
rewrite 502 /500.json
rewrite 404 /404.json
file_server
}
}
# Default upstream
import common-proxy8080
}
(common-mailhog) {
handle /mailhog/* {
basic_auth {
{$EMAIL} XXXXXXXXXXXXXXXXXXXXX
}
reverse_proxy 127.0.0.1:8025
}
}
(common-video) {
# TLS via Cloudflare DNS for video hosts
import cloudflare-tls
# Security headers, gzip, etc.
import common-security
# Apply WAF by default (your app is serving the video files)
import common-waf
# MP4 proxy (shared)
@mp4 path *.mp4
handle @mp4 {
reverse_proxy 127.0.0.1:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_timeout 300s
}
header_up Connection {header.Connection}
header_up Upgrade {header.Upgrade}
}
}
}
The file referenced via ACTIVE_SITE
# Dev: squarephone.biz (same app/WAF/heartbeat as onepub.dev)
squarephone.biz {
# Typically keep HSTS off for dev to avoid stickiness; remove '-' to enable.
header {
-Strict-Transport-Security
}
import cloudflare-tls
import common-security
import common-app-routes
import common-mailhog
}