Unable to get cert with wildcard

1. The problem I’m having:

DNS Challenge (Digital Ocean) for my Caddy server (v2.9.1) with a wildcard domain doesn’t work.
I use Caddy through FrankenPhp builder.
That worked few months ago but I destroyed and recreate my digital ocean droplet and all networking config.

I configured DNS records for wildcard ( A *.aboulbox.com directs to XXX.XXX.XXX.XXX).

When I test Digital Ocean API to access domain config, it works:

curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer api_token" "https://api.digitalocean.com/v2/domains/aboulbox.com/records"

I’m sure it’s a stupid thing that I miss but I don’t know what…

2. Error messages and/or full log output:

2025/04/29 18:12:06.563	ERROR	cleaning up solver	{"identifier": "*.aboulbox.com", "challenge_type": "dns-01", "error": "no memory of presenting a DNS record for \"_acme-challenge.aboulbox.com\" (usually OK if presenting also failed)"}
github.com/mholt/acmez/v3.(*Client).solveChallenges.func1
	/root/go/pkg/mod/github.com/mholt/acmez/v3@v3.1.1/client.go:318
github.com/mholt/acmez/v3.(*Client).solveChallenges
	/root/go/pkg/mod/github.com/mholt/acmez/v3@v3.1.1/client.go:363
github.com/mholt/acmez/v3.(*Client).ObtainCertificate
	/root/go/pkg/mod/github.com/mholt/acmez/v3@v3.1.1/client.go:136
github.com/caddyserver/certmagic.(*ACMEIssuer).doIssue
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/acmeissuer.go:489
github.com/caddyserver/certmagic.(*ACMEIssuer).Issue
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/acmeissuer.go:382
github.com/caddyserver/caddy/v2/modules/caddytls.(*ACMEIssuer).Issue
	/root/go/pkg/mod/github.com/caddyserver/caddy/v2@v2.9.1/modules/caddytls/acmeissuer.go:249
github.com/caddyserver/certmagic.(*Config).obtainCert.func2
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/config.go:626
github.com/caddyserver/certmagic.doWithRetry
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/async.go:104
github.com/caddyserver/certmagic.(*Config).obtainCert
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/config.go:700
github.com/caddyserver/certmagic.(*Config).ObtainCertAsync
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/config.go:505
github.com/caddyserver/certmagic.(*Config).manageOne.func1
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/config.go:415
github.com/caddyserver/certmagic.(*jobManager).worker
	/root/go/pkg/mod/github.com/caddyserver/certmagic@v0.22.2/async.go:73
2025/04/29 18:12:06.699	ERROR	tls.obtain	could not get certificate from issuer	{"identifier": "*.aboulbox.com", "issuer": "acme-v02.api.letsencrypt.org-directory", "error": "[*.aboulbox.com] solving challenges: presenting for challenge: adding temporary record for zone \"aboulbox.com.\": Post \"https://api.digitalocean.com/v2/domains/aboulbox.com/records\": oauth2: token expired and refresh token is not set (order=https://acme-staging-v02.api.letsencrypt.org/acme/order/197355664/24222599644) (ca=https://acme-staging-v02.api.letsencrypt.org/directory)"}
2025/04/29 18:12:06.699	INFO	tls.issuance.acme	using ACME account	{"account_id": "https://acme.zerossl.com/v2/DV90/account/0f_nCQmPJCl7VvF_sqstrA", "account_contact": ["mailto:mymail@gmail.com"]}
2025/04/29 18:12:06.911	INFO	trying to solve challenge	{"identifier": "aboulbox.com", "challenge_type": "dns-01", "ca": "https://acme.zerossl.com/v2/DV90"}
2025/04/29 18:12:06.912	ERROR	cleaning up solver	{"identifier": "aboulbox.com", "challenge_type": "dns-01", "error": "no memory of presenting a DNS record for \"_acme-challenge.aboulbox.com\" (usually OK if presenting also failed)"}

3. Caddy version:

v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY=

4. How I installed and ran Caddy:

Build from FrankenPhp Dockerfile, using xcaddy.

Dockerfile:

#syntax=docker/dockerfile:1.4

# Versions
FROM dunglas/frankenphp:1.5-builder-php8.3 AS builder

# Copy xcaddy in the builder image
COPY --from=caddy:2.9.1-builder /usr/bin/xcaddy /usr/bin/xcaddy

# CGO must be enabled to build FrankenPHP
ENV CGO_ENABLED=1 XCADDY_SETCAP=1 XCADDY_GO_BUILD_FLAGS="-ldflags '-w -s'"
RUN xcaddy build v2.9.1 \
	--output /usr/local/bin/frankenphp \
	--with github.com/dunglas/frankenphp/caddy \
	--with github.com/caddy-dns/digitalocean \
	--with github.com/dunglas/caddy-cbrotli \
	--with github.com/greenpau/caddy-security
# Mercure and Vulcain are included in the official build, but feel free to remove them
# Add extra Caddy modules here

FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream

# Replace the official binary by the one contained your custom modules
COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp

# Base FrankenPHP image
FROM frankenphp_upstream AS frankenphp_base

WORKDIR /app

VOLUME /app/var/

# persistent / runtime deps
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
	acl \
	file \
	gettext \
	git \
	nano \
	locales \
	iputils-ping \
	&& rm -rf /var/lib/apt/lists/*

RUN set -eux; \
	install-php-extensions \
	@composer \
	apcu \
	intl \
	opcache \
	zip \
	gd \
	;

RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'

# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
ENV COMPOSER_ALLOW_SUPERUSER=1

ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"

###> recipes ###
###> doctrine/doctrine-bundle ###
RUN install-php-extensions pdo_pgsql
###< doctrine/doctrine-bundle ###
###< recipes ###

COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/
COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile

ENTRYPOINT ["docker-entrypoint"]

HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]

# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev

ENV APP_ENV=dev XDEBUG_MODE=off

RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"

RUN set -eux; \
	install-php-extensions \
	xdebug \
	;

COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/

CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]

# Prod FrankenPHP image
FROM frankenphp_base AS frankenphp_prod

ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"

RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile

# prevent the reinstallation of vendors at every changes in the source code
COPY --link composer.* symfony.* ./
RUN set -eux; \
	composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress

# copy sources
COPY --link . ./
RUN rm -Rf frankenphp/

RUN set -eux; \
	mkdir -p var/cache var/log; \
	composer dump-autoload --classmap-authoritative --no-dev --optimize; \
	composer dump-env prod; \
	composer run-script --no-dev post-install-cmd; \
	chmod +x bin/console; sync;

a. System environment:

root@2261eaef1f85:/app# frankenphp environ
caddy.HomeDir=/root
caddy.AppDataDir=/data/caddy
caddy.AppConfigDir=/config/caddy
caddy.ConfigAutosavePath=/config/caddy/autosave.json
caddy.Version=v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY=
runtime.GOOS=linux
runtime.GOARCH=amd64
runtime.Compiler=gc
runtime.NumCPU=2
runtime.GOMAXPROCS=2
runtime.Version=go1.24.2
os.Getwd=/app

SERVER_NAME=aboulbox.com
APP_CATCHA_TOKEN=
DO_API_TOKEN=api_token
CADDY_GLOBAL_OPTIONS=acme_dns digitalocean {api_token}

ADMIN_PASSWORD_HASH=password
STABILITY=stable
DATABASE_URL=postgresql://main:main@database:5432/main?serverVersion=15&charset=utf8
HOSTNAME=2261eaef1f85
LANGUAGE=en_US:en
PHP_VERSION=8.3.20
PHP_INI_DIR=/usr/local/etc/php
GPG_KEYS=
XDG_DATA_HOME=/data
GODEBUG=cgocheck=0
XDG_CONFIG_HOME=/config
APP_CONSTRUCTION=1
PHP_LDFLAGS=-Wl,-O1 -pie
PWD=/app
HOME=/root
LANG=en_US.UTF-8
APP_ENV=prod
ADMIN_PASSWORD=admin
PHP_SHA256=f15914e071b5bddaf1475b5f2ba68107e8b8846655f9e89690fb7cd410b0db6c
PHPIZE_DEPS=autoconf 		dpkg-dev 		file 		g++ 		gcc 		libc-dev 		make 		pkg-config 		re2c
TERM=xterm
PHP_URL=https://www.php.net/distributions/php-8.3.20.tar.xz
ADMIN_USER=admin
SHLVL=1
COMPOSER_ALLOW_SUPERUSER=1
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
UMAMI_WEBSITE_ID=
FRANKENPHP_CONFIG=import worker.Caddyfile
LC_ALL=en_US.UTF-8
MAILER_DSN=
HMAC_KEY=
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PHP_INI_SCAN_DIR=:/usr/local/etc/php/app.conf.d
SYMFONY_VERSION=
PHP_ASC_URL=https://www.php.net/distributions/php-8.3.20.tar.xz.asc
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
APP_SECRET=
_=/usr/local/bin/frankenphp

c. Service/unit/compose file:

docker-compose.yml:

services:
  php:
    image: ${IMAGES_PREFIX:-}app-php
    restart: unless-stopped
    container_name: php
    environment:
      SERVER_NAME: ${SERVER_NAME:-localhost}
      # Run "composer require symfony/orm-pack" to install and configure Doctrine ORM
      DATABASE_URL: postgresql://${POSTGRES_USER:-main}:${POSTGRES_PASSWORD:-main}@database:5432/${POSTGRES_DB:-main}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
      SYMFONY_VERSION: ${SYMFONY_VERSION:-}
      STABILITY: ${STABILITY:-stable}
      ADMIN_USER: ${ADMIN_USER:-admin}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
      ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-password}
      DO_API_TOKEN: ${DO_API_TOKEN}
    volumes:
      - caddy_data:/data
      - caddy_config:/config
      - ./caddy-data/caddy/auth/:/etc/gatekeeper/auth
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    networks:
      - monitor-net

  otterwiki:
    image: redimp/otterwiki:2
    container_name: otterwiki
    restart: unless-stopped
    volumes:
      - ./otterwiki:/app-data
    networks:
      - monitor-net

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    environment:
      DATABASE_TYPE: postgresql
      APP_SECRET: ${APP_SECRET}
    restart: unless-stopped
    env_file: ".umami.env"
    depends_on:
      - database
      - php
    labels:
      org.label-schema.group: "analytics"
    networks:
      - monitor-net

  dashy:
    image: lissy93/dashy
    container_name: dashy
    restart: unless-stopped
    volumes:
      - ./dashy/user-data:/app/user-data
    environment:
      - NODE_ENV=production
    depends_on:
      - php
    networks:
      - monitor-net

  database:
    image: tensorchord/pgvecto-rs:pg16-v0.2.1
    container_name: database
    environment:
      POSTGRES_PASSWORD: main
      POSTGRES_USER: main
      POSTGRES_DB: main
    ports:
      - "5432"
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - monitor-net

# Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###

volumes:
  postgres_data: {}
  caddy_config:
  caddy_data:

networks:
  monitor-net:
    driver: bridge

d. My complete Caddy config:

Caddyfile:

############
# Globals #
############
{
	{$CADDY_GLOBAL_OPTIONS}

	grace_period 10s
	email mymail@gmail.com
	default_sni {$SERVER_NAME}

	order authenticate before respond
	order authorize before reverse_proxy

	security {
		local identity store localdb {
			realm local
			path /data/caddy/.local/users.json
		}

		authentication portal myportal {
			enable identity store localdb
			crypto default token lifetime 86400
			cookie lifetime 86400
			crypto key verify from file /etc/gatekeeper/auth/jwt/sign_key.pem

			ui {
				links {
					"Dashboard" https://dash.{$SERVER_NAME:localhost}
					"Monitoring" https://monitor.{$SERVER_NAME:localhost}
					"Wiki" https://wiki.{$SERVER_NAME:localhost}
					"Analytics" https://analytics.{$SERVER_NAME:localhost}
					"Immich" https://dash.{$SERVER_NAME:localhost}
					"Tandoor" https://tandoor.{$SERVER_NAME:localhost}
				}
			}
		}

		authorization policy users_policy {
			set auth url https://auth.{$SERVER_NAME:localhost}
			bypass uri prefix /api

			# Verify signed key
			crypto key sign-verify from file /etc/gatekeeper/auth/jwt/verify_key.pem

			## Inject header to downstream
			inject headers with claims
			inject header "X-JWT-Assertion" from {http.request.cookie.access_token}
			allow roles authp/admin authp/user
		}
	}

	frankenphp {
		{$FRANKENPHP_CONFIG}
	}
}

{$CADDY_EXTRA_CONFIG}

############
# Snippets #
############
(main) {
	log {
		format filter {
			wrap json
			fields {
				common_log delete
				request>tls delete
			}
		}
		level error
	}

	respond /robots.txt 200 {
		body "User-agent: *
		Disallow: /

		User-agent: AdsBot-Google
		Disallow: /

		User-agent: AdsBot-Google-Mobile
		Disallow: /"
		close
	}
}

###############
# aboulbox.com #
###############
https://{$SERVER_NAME:localhost} {
	import main

	root * /app/public
	encode zstd br gzip 

	{$CADDY_SERVER_EXTRA_DIRECTIVES}

	# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
	header ?Permissions-Policy "browsing-topics=()"

	php_server
}

https://*.{$SERVER_NAME:localhost} {
	import main

	@auth host auth.{$SERVER_NAME:localhost}
	@monitor host monitor.{$SERVER_NAME:localhost}
	@mail host mail.{$SERVER_NAME:localhost}
	@wiki host wiki.{$SERVER_NAME:localhost}
	@analytics host analytics.{$SERVER_NAME:localhost}
	@dash host dash.{$SERVER_NAME:localhost}
	@gallery host gallery.{$SERVER_NAME:localhost}
	@tandoor host tandoor.{$SERVER_NAME:localhost}

	handle @auth {
		authenticate with myportal
	}

	handle @wiki {
		authorize with users_policy

		reverse_proxy otterwiki {
			header_up x-otterwiki-name "Aboul"
			header_up x-otterwiki-email "mymail@gmail.com"
			header_up x-otterwiki-permissions "READ,WRITE,UPLOAD,ADMIN"
		}
	}

	handle @analytics {
		authorize with users_policy

		reverse_proxy umami:3000
	}

	handle @dash {
		authorize with users_policy

		reverse_proxy dashy:8080
	}

	handle {
		redir https://www.youtube.com/watch?v=xm3YgoEiEDc 302
	}
}

oauth2: token expired and refresh token is not set

Looks like a bug in the digitalocean DNS provider, is my guess. Try filing an issue there: GitHub - libdns/digitalocean

Hello! Thanks for your reply.
Unfortunately, this repo has not been maintained for 4 years. Is there another repo for digital ocean dns ?
Other question: is there a way to show the request sent to digital ocean API? In logs/console or elsewhere?

I tried to change libdns/digitalocean in xcaddy building command, like this:

RUN xcaddy build \
	--output /usr/local/bin/frankenphp \
	--with github.com/dunglas/frankenphp/caddy \
	--with github.com/caddy-dns/digitalocean=github.com/xnok/caddy-dns-digitalocean@master \
	--replace github.com/libdns/digitalocean=github.com/xNok/libdns-digitalocean@master \
	--with github.com/dunglas/caddy-cbrotli \
	--with github.com/greenpau/caddy-security

xnok/caddy-dns-digitalocean is a fork that use last libdns stable version, allowing me to use Caddy v2.10.0.

Now, I’ve got that logs:

2025/04/30 18:54:48.103	ERROR	tls.obtain	could not get certificate from issuer	{"identifier": "example.com", "issuer": "acme.zerossl.com-v2-DV90", "error": "[example.com] solving challenges: presenting for challenge: adding temporary record for zone \"example.com.\": POST https://api.digitalocean.com/v2/domains/example.com/records: 401 (request \"8df26329-ef0a-4a8e-a8b0-daadd1751db1\") Unable to authenticate you (order=https://acme.zerossl.com/v2/DV90/order/Wbm8M3LfVC7yK6qYue-mNQ) (ca=https://acme.zerossl.com/v2/DV90)"}
2025/04/30 18:54:48.103	ERROR	tls.obtain	will retry	{"error": "[example.com] Obtain: [example.com] solving challenges: presenting for challenge: adding temporary record for zone \"example.com.\": POST https://api.digitalocean.com/v2/domains/example.com/records: 401 (request \"8df26329-ef0a-4a8e-a8b0-daadd1751db1\") Unable to authenticate you (order=https://acme.zerossl.com/v2/DV90/order/Wbm8M3LfVC7yK6qYue-mNQ) (ca=https://acme.zerossl.com/v2/DV90)", "attempt": 5, "retrying_in": 600, "elapsed": 822.791440439, "max_duration": 2592000}
2025/04/30 18:54:48.890	ERROR	tls.obtain	could not get certificate from issuer	{"identifier": "*.example.com", "issuer": "acme.zerossl.com-v2-DV90", "error": "[*.example.com] solving challenges: presenting for challenge: adding temporary record for zone \"example.com.\": POST https://api.digitalocean.com/v2/domains/example.com/records: 401 (request \"2f0bdfd3-e808-49a2-9a68-6873eaa5b3d6\") Unable to authenticate you (order=https://acme.zerossl.com/v2/DV90/order/u2wTwKST4kRjNzytKhcsnw) (ca=https://acme.zerossl.com/v2/DV90)"}
2025/04/30 18:54:48.890	ERROR	tls.obtain	will retry	{"error": "[*.example.com] Obtain: [*.example.com] solving challenges: presenting for challenge: adding temporary record for zone \"example.com.\": POST https://api.digitalocean.com/v2/domains/example.com/records: 401 (request \"2f0bdfd3-e808-49a2-9a68-6873eaa5b3d6\") Unable to authenticate you (order=https://acme.zerossl.com/v2/DV90/order/u2wTwKST4kRjNzytKhcsnw) (ca=https://acme.zerossl.com/v2/DV90)", "attempt": 5, "retrying_in": 600, "elapsed": 823.570633967, "max_duration": 2592000}

Quite different but I don’t understand why I’m getting 401 response. Any idea to debug that?

No idea, I’m not familiar with DigitalOcean’s DNS API. If it’s not being maintained then someone else can offer to step up and I can give them commit privileges.