Caddy with Cloudflare DNS Plugin
This document explains the custom Caddy Docker image with Cloudflare DNS plugin integration used by BridgeBeats.
Overview
BridgeBeats uses a custom Caddy image that includes the Cloudflare DNS plugin. This enables automated certificate management for wildcard domains using DNS-01 ACME challenges, which is required for the ATProto PDS (Personal Data Server) subdomain wildcards.
Why Cloudflare DNS Plugin?
The ATProto PDS requires wildcard DNS support (*.pds.bridgebeats.link) to handle user-specific subdomains. Standard HTTP-01 ACME challenges cannot validate wildcard certificates, so BridgeBeats uses DNS-01 challenges via the Cloudflare API. The Cloudflare DNS challenge is also used for the dashboard.<domain> subdomain certificate.
Features
- Wildcard certificate support - Obtain and renew wildcard SSL/TLS certificates for the PDS subdomains
- DNS-01 challenge - Uses the Cloudflare API for ACME DNS challenge validation
- Automatic renewal - Certificates renew before expiration
- Multi-architecture - Built for both
linux/amd64andlinux/arm64platforms
Docker Image
The custom Caddy image is published to Docker Hub:
tsmarvin/caddy-cloudflare:latest
Image Tags
latest- Built from themainbranch (production)develop- Built from thedevelopbranch (development)pr-<number>- Built from pull requests (testing)
The shipped containers/docker-compose.yml references tsmarvin/caddy-cloudflare:develop for the bridgebeats-caddy service. Pin the tag you want for your environment.
Configuration
1. Cloudflare API Token
Create a Cloudflare API token with the following permissions:
- Go to Cloudflare Dashboard
- Click "Create Token"
- Use the "Edit zone DNS" template or create a custom token with:
- Permissions:
Zone:DNS:Edit - Zone Resources: Include your domain (e.g.,
bridgebeats.link)
- Permissions:
- Copy the generated token
2. Provide the token to Caddy
The token reaches Caddy as the CLOUDFLARE_API_TOKEN environment variable. The compose stack sources it from .env and passes it to the bridgebeats-caddy service; the Caddyfile then reads it with {env.CLOUDFLARE_API_TOKEN}.
Set the value in .env:
# .env
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
The installation script also creates a placeholder secrets/cloudflare_api_token.txt. The compose stack as shipped consumes the token from the CLOUDFLARE_API_TOKEN environment variable rather than mounting that file into the Caddy container, so the value you set in .env is what Caddy uses.
Verify: if you change the deployment to mount
cloudflare_api_token.txtas a Docker/Swarm secret instead of passing the env var, update the Caddyfiletlsblocks to read{file /run/secrets/cloudflare_api_token}and add the corresponding mount to thebridgebeats-caddyservice.
3. Docker Compose Configuration
The bridgebeats-caddy service passes the token via environment: (see containers/docker-compose.yml):
bridgebeats-caddy:
image: tsmarvin/caddy-cloudflare:develop
container_name: bridgebeats-caddy
ports:
- "80:80"
- "443:443"
networks:
- public
- internal
environment:
- DOMAIN=${DOMAIN:-bridgebeats.link}
- SHORT_DOMAIN=${SHORT_DOMAIN:-bbeats.link}
- PDS_HOSTNAME=${PDS_HOSTNAME:-pds.bridgebeats.link}
- CADDY_ADMIN_EMAIL=${CADDY_ADMIN_EMAIL:[email protected]}
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- bridgebeats-caddy-data:/data/caddy
- bridgebeats-caddy-config:/config/caddy
- ./logs/caddy:/var/log/caddy
4. Caddyfile Configuration
The wildcard PDS site in containers/Caddyfile uses the Cloudflare DNS challenge, reading the token from the environment:
*.{$PDS_HOSTNAME}, {$PDS_HOSTNAME} {
# Use Cloudflare DNS challenge for wildcard certificate
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
reverse_proxy bridgebeats-pds:3000
}
The dashboard.{$DOMAIN} site uses the same dns cloudflare {env.CLOUDFLARE_API_TOKEN} challenge. The primary site ({$DOMAIN}, www, and the short domain) does not need the DNS challenge and uses standard ACME issuance.
Deployment
Using Docker Compose
From the containers/ directory:
Configure environment:
cd containers cp .env.example .env nano .env # Set DOMAIN, CADDY_ADMIN_EMAIL, PDS_HOSTNAME, and CLOUDFLARE_API_TOKENStart the services:
docker compose up -dVerify certificate issuance:
docker compose logs bridgebeats-caddy | grep -i certificate
Manual Docker Build
To build the custom Caddy image locally (from the repository root):
docker build -f containers/Dockerfile.caddy -t caddy-cloudflare:local .
Automated Publishing
The custom Caddy image is built and published via GitHub Actions when changes to containers/Dockerfile.caddy or the workflow file are pushed to:
mainbranch →latesttagdevelopbranch →developtag- Pull requests →
pr-<number>tag
Workflow File
.github/workflows/publish-caddy-cloudflare.yml
The workflow:
- Builds per-architecture images for
linux/amd64andlinux/arm64 - Pushes each architecture by digest to Docker Hub
- Creates a multi-arch manifest list
- Emits SBOM and provenance attestations (
sbom: true,provenance: mode=max) - Requires the
DOCKERHUB_USERNAMEandDOCKERHUB_TOKENsecrets
See the SBOM and Provenance Guide for how to inspect the Caddy image's attestations.
Security Considerations
API Token Scope
Important: Grant only the minimum required permissions to your Cloudflare API token:
- Zone:DNS:Edit - Required for DNS-01 challenges
- Avoid granting additional permissions (account-level access, zone settings, etc.)
Token Storage
- Keep the token in
.env(or in your orchestrator's secret store); never commit it to version control .envandsecrets/are excluded via.gitignore- For Docker Swarm or Kubernetes, supply the token through the platform's secret mechanism
- Rotate tokens regularly
Network Security
The Caddy container is the only service published on the host (ports 80/443). It joins both the public and internal bridge networks; the application and Redis join only internal:
networks:
public:
driver: bridge
internal:
driver: bridge
Note: In the shipped
docker-compose.ymltheinternalnetwork is a standard bridge and is not declared withinternal: true. Isolation comes from not publishing the app and Redis ports to the host, not from a Docker-levelinternalflag. If you require strict no-egress isolation for the internal network, addinternal: trueto its definition and verify the PDS and outbound provider calls still function.
Troubleshooting
Certificate Not Issued
Issue: Caddy fails to obtain the wildcard certificate
Solutions:
Confirm the token is present in the container environment:
docker compose exec bridgebeats-caddy printenv CLOUDFLARE_API_TOKENCheck Caddy logs:
docker compose logs bridgebeats-caddyVerify DNS propagation:
dig _acme-challenge.pds.bridgebeats.link TXTEnsure the API token has
Zone:DNS:Editfor the correct zone in the Cloudflare dashboard
Rate Limiting
Issue: Let's Encrypt rate limit exceeded
Solution: Let's Encrypt enforces per-domain certificate rate limits. Use the staging environment for testing:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Image Pull Failures
Issue: Cannot pull the custom Caddy image
Solutions:
- Verify the image tag in
docker-compose.ymlmatches a published tag (latest,develop, or a PR tag) - Check that the image exists on Docker Hub
- Ensure the host has internet access
- Try pulling manually:
docker pull tsmarvin/caddy-cloudflare:develop
References
- Caddy Documentation
- Caddy Cloudflare DNS Provider
- Cloudflare API Tokens
- Let's Encrypt Rate Limits
- ACME DNS-01 Challenge
Contributing
To modify the Caddy image:
- Edit
containers/Dockerfile.caddy - Test locally:
docker build -f containers/Dockerfile.caddy -t test . - Open a pull request
- The workflow builds and pushes the image on merge to
developormain
Version Information
- Builder base image:
caddy:2-builder(digest-pinned inDockerfile.caddy) - Runtime base image:
caddy:2(digest-pinned inDockerfile.caddy) - Cloudflare plugin:
github.com/caddy-dns/cloudflare, built in viaxcaddy