FrankenPHP with Caddy automatically generates and renews Let's Encrypt certificates for your domain. These certificates are stored in Docker volumes to persist across container restarts and updates.
By default, Docker Compose volumes are deleted when running docker compose down -v. This causes a new Let's Encrypt certificate to be requested on each deployment, which can quickly hit Let's Encrypt's rate limits:
- 5 certificates per domain per week (main limit)
- 50 certificates per registered domain per week
After 5 container recreations in a week, you'll be blocked from obtaining new certificates.
In production (compose.prod.yaml), the project uses external volumes that persist independently of the Docker Compose stack. These volumes are never deleted, even with docker compose down -v.
Before your first production deployment, create the external volumes:
docker volume create caddy_data
docker volume create caddy_configThese commands need to be run only once on your production server.
Once the volumes are created, deploy normally:
# Pull latest production image from DockerHub
docker compose -f compose.yaml -f compose.prod.yaml pull
# Start container (certificates will be requested on first run only)
SERVER_NAME=your-domain-name.example.com \
APP_SECRET=your-secret \
docker compose -f compose.yaml -f compose.prod.yaml up -dNote: The production image is automatically built and published to DockerHub by CI/CD on every push to main. No build step is required on the server.
On the first deployment, Caddy will request a Let's Encrypt certificate and store it in the caddy_data volume.
On subsequent deployments, Caddy will:
- Find the existing certificate in the volume
- Use it if still valid
- Automatically renew it when it's close to expiration (typically 30 days before)
You can now safely redeploy without requesting new certificates:
# Stop and remove containers (volumes are preserved)
docker compose -f compose.yaml -f compose.prod.yaml down
# Pull latest image from DockerHub (if updated)
docker compose -f compose.yaml -f compose.prod.yaml pull
# Restart (existing certificates will be reused)
docker compose -f compose.yaml -f compose.prod.yaml up -dSimpler update command:
docker compose -f compose.yaml -f compose.prod.yaml pull && \
docker compose -f compose.yaml -f compose.prod.yaml up -dImportant: Never use docker compose down -v in production, as this would attempt to remove the volumes (though external volumes are protected).
Check that your certificates are stored in the external volume:
# List contents of caddy_data volume
docker run --rm -v caddy_data:/data alpine ls -la /data/caddy/certificates/
# Check certificate expiration
docker exec $(docker compose ps -q php) cat /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*/your-domain.crt | openssl x509 -noout -datesIf you already have a deployment with non-external volumes:
-
Export existing certificates (if any):
docker run --rm -v geoloc-map_caddy_data:/source -v $(pwd)/backup:/backup alpine tar czf /backup/caddy_data.tar.gz -C /source .
-
Create external volumes:
docker volume create caddy_data docker volume create caddy_config
-
Import certificates to new volumes (optional, only if you have valid certificates):
docker run --rm -v caddy_data:/target -v $(pwd)/backup:/backup alpine tar xzf /backup/caddy_data.tar.gz -C /target -
Remove old stack (this will remove old volumes):
docker compose -f compose.yaml -f compose.prod.yaml down -v
-
Deploy with new configuration:
docker compose -f compose.yaml -f compose.prod.yaml up --wait
It's good practice to backup your certificates periodically:
docker run --rm -v caddy_data:/source -v $(pwd)/backup:/backup alpine tar czf /backup/caddy_data_$(date +%Y%m%d).tar.gz -C /source .If you need to restore certificates:
docker run --rm -v caddy_data:/target -v $(pwd)/backup:/backup alpine sh -c "rm -rf /target/* && tar xzf /backup/caddy_data_YYYYMMDD.tar.gz -C /target"If you've hit the Let's Encrypt rate limit:
- Wait: Rate limits reset after one week
- Use staging: Test with Let's Encrypt staging environment:
CADDY_GLOBAL_OPTIONS="debug\nacme_ca https://acme-staging-v02.api.letsencrypt.org/directory" \ docker compose -f compose.yaml -f compose.prod.yaml up - Check volumes: Ensure external volumes exist and contain certificates:
docker volume ls | grep caddy docker volume inspect caddy_data
If certificates are still being requested on each restart:
-
Verify external volumes are configured:
docker compose -f compose.yaml -f compose.prod.yaml config | grep -A 3 "volumes:"
You should see
external: truefor caddy volumes. -
Check volume mounts:
docker inspect $(docker compose ps -q php) | grep -A 10 Mounts
Verify
/dataand/configare mounted to external volumes. -
Check Caddy logs:
docker compose -f compose.yaml -f compose.prod.yaml logs php | grep -i cert
Caddy automatically handles certificate renewal:
- Certificates are renewed ~30 days before expiration
- No manual intervention required
- Renewal happens in the background while the server is running
- The new certificate is loaded without downtime
- Development (
compose.yaml): Uses regular Docker volumes, certificates can be recreated freely - Production (
compose.prod.yaml): Uses external volumes, certificates persist indefinitely
This ensures you don't hit rate limits during development while maintaining certificate persistence in production.