Configure Caddy 2 web server with automatic SSL certificate provisioning using Let's Encrypt and DNS challenge authentication for secure HTTPS automation.
Prerequisites
- Root or sudo access
- Domain with DNS provider API access
- DNS provider API credentials
What this solves
Caddy 2 provides automatic HTTPS with zero configuration, but DNS challenges are essential when your server isn't publicly accessible or you need wildcard certificates. This tutorial configures Caddy with Let's Encrypt DNS challenges for automatic SSL certificate provisioning and renewal.
Step-by-step installation
Update system packages
Start by updating your package manager to ensure you get the latest versions available.
sudo apt update && sudo apt upgrade -yInstall required packages
Install curl and other essential tools needed for Caddy installation and DNS provider configuration.
sudo apt install -y curl wget gnupg lsb-releaseAdd Caddy repository
Add the official Caddy repository to get the latest stable version with automatic updates.
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt updateInstall Caddy 2
Install Caddy 2 from the official repository to get the latest features and security updates.
sudo apt install -y caddyCreate Caddy configuration directory
Create the necessary directories for Caddy configuration and ensure proper ownership for the caddy user.
sudo mkdir -p /etc/caddy
sudo chown -R caddy:caddy /etc/caddy
sudo chmod 755 /etc/caddyConfigure Caddy with DNS challenge
Create the main Caddy configuration file with DNS challenge settings for your DNS provider. This example uses Cloudflare.
{
email admin@example.com
acme_ca https://acme-v02.api.letsencrypt.org/directory
}
example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
root * /var/www/html
file_server
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/access.log
format json
}
}
*.example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
@api host api.example.com
handle @api {
reverse_proxy localhost:3000
}
@app host app.example.com
handle @app {
reverse_proxy localhost:8080
}
handle {
respond "Subdomain not configured" 404
}
}Create DNS provider environment file
Create a secure environment file containing your DNS provider credentials. Never store these in the main configuration file.
sudo touch /etc/caddy/caddy.env
sudo chown caddy:caddy /etc/caddy/caddy.env
sudo chmod 600 /etc/caddy/caddy.env# Cloudflare API Token
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token_here
Alternative DNS providers:
ROUTE53_ACCESS_KEY_ID=your_key_id
ROUTE53_SECRET_ACCESS_KEY=your_secret_key
ROUTE53_REGION=us-east-1
DIGITALOCEAN_TOKEN=your_do_token
NAMECHEAP_API_USER=your_username
NAMECHEAP_API_KEY=your_api_key
Create web root directory
Create the web root directory and set proper permissions for serving static content.
sudo mkdir -p /var/www/html
sudo chown -R caddy:caddy /var/www/html
sudo chmod 755 /var/www/htmlCreate log directory
Create the log directory with proper permissions for Caddy access and error logs.
sudo mkdir -p /var/log/caddy
sudo chown -R caddy:caddy /var/log/caddy
sudo chmod 755 /var/log/caddyCreate systemd override for environment variables
Configure systemd to load the DNS provider credentials securely without exposing them in process lists.
sudo mkdir -p /etc/systemd/system/caddy.service.d[Service]
EnvironmentFile=/etc/caddy/caddy.env
Restart=always
RestartSec=5
TimeoutStopSec=30
Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/caddy /var/log/caddy /var/www
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICETest Caddy configuration
Validate your Caddy configuration file before starting the service to catch syntax errors early.
sudo caddy validate --config /etc/caddy/CaddyfileReload systemd and enable Caddy
Reload systemd to apply the override configuration and enable Caddy to start automatically on boot.
sudo systemctl daemon-reload
sudo systemctl enable caddy
sudo systemctl start caddyConfigure DNS challenge providers
Cloudflare DNS challenge
Create a Cloudflare API token with Zone:Zone:Read and Zone:DNS:Edit permissions for your domain.
example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Your site configuration
}AWS Route53 DNS challenge
Configure Route53 with IAM credentials that have Route53 permissions for your hosted zone.
example.com {
tls {
dns route53 {
access_key_id {env.ROUTE53_ACCESS_KEY_ID}
secret_access_key {env.ROUTE53_SECRET_ACCESS_KEY}
region {env.ROUTE53_REGION}
}
}
# Your site configuration
}DigitalOcean DNS challenge
Use your DigitalOcean personal access token with read and write permissions for DNS records.
example.com {
tls {
dns digitalocean {env.DIGITALOCEAN_TOKEN}
}
# Your site configuration
}Advanced SSL configuration
Configure custom ACME server
Use a custom ACME server like your own Boulder instance or a different certificate authority.
{
email admin@example.com
acme_ca https://your-acme-server.com/acme/directory
acme_ca_root /path/to/custom-ca-cert.pem
}
example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
ca https://your-acme-server.com/acme/directory
}
# Your site configuration
}Configure certificate lifetime and renewal
Customize certificate renewal thresholds and configure email notifications for certificate events.
{
email admin@example.com
cert_lifetime 90d
renew_interval 30d
log default {
output file /var/log/caddy/caddy.log
level INFO
format json
}
}
example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
lifetime 90d
must_staple
}
# Your site configuration
}Configure multiple DNS providers
Use different DNS providers for different domains when managing multiple zones across providers.
example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Configuration for Cloudflare domain
}
example.net {
tls {
dns route53 {
access_key_id {env.ROUTE53_ACCESS_KEY_ID}
secret_access_key {env.ROUTE53_SECRET_ACCESS_KEY}
region us-east-1
}
}
# Configuration for Route53 domain
}
example.org {
tls {
dns digitalocean {env.DIGITALOCEAN_TOKEN}
}
# Configuration for DigitalOcean domain
}Security hardening
Configure firewall rules
Open only the necessary ports for HTTP and HTTPS traffic while blocking all other incoming connections.
sudo ufw enable
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status verboseConfigure log rotation
Set up automatic log rotation to prevent Caddy logs from consuming all available disk space.
/var/log/caddy/*.log {
daily
missingok
rotate 52
compress
delaycompress
notifempty
sharedscripts
postrotate
systemctl reload caddy > /dev/null 2>&1 || true
endscript
}Set up monitoring and alerting
Configure basic monitoring to track certificate expiration and service health.
# Check certificate expiration daily at 2 AM
0 2 * caddy /usr/bin/caddy list-certificates --config /etc/caddy/Caddyfile | grep -E "expires|error" | mail -s "Caddy Certificate Status" admin@example.comVerify your setup
Check that Caddy is running properly and certificates are being issued automatically.
sudo systemctl status caddy
sudo caddy list-certificates --config /etc/caddy/Caddyfile
curl -I https://example.com
openssl s_client -connect example.com:443 -servername example.com < /dev/null | openssl x509 -text -noout | grep -A2 "Validity"Check the Caddy logs for any DNS challenge errors or certificate provisioning issues.
sudo journalctl -u caddy -f --no-pager
sudo tail -f /var/log/caddy/caddy.logTest that your DNS challenges work by forcing a certificate renewal.
sudo caddy reload --config /etc/caddy/CaddyfileCommon issues
| Symptom | Cause | Fix |
|---|---|---|
| DNS challenge fails | Invalid API token or insufficient permissions | Verify token permissions and test with DNS provider API |
| Certificate not issued | Domain not pointing to server or DNS propagation delay | Wait for DNS propagation, verify A/AAAA records |
| Permission denied errors | Incorrect file ownership or directory permissions | sudo chown -R caddy:caddy /etc/caddy /var/log/caddy |
| Caddy won't start | Configuration syntax error | sudo caddy validate --config /etc/caddy/Caddyfile |
| Environment variables not loaded | systemd override not applied | sudo systemctl daemon-reload && sudo systemctl restart caddy |
| Wildcard certificates not working | DNS provider doesn't support wildcards or incorrect configuration | Verify provider supports wildcards, check DNS plugin documentation |
Next steps
- Configure NGINX SSL termination with Certbot for alternative SSL automation
- Implement Caddy 2 rate limiting and DDoS protection for advanced security
- Configure Caddy reverse proxy with load balancing for high availability
- Setup Caddy monitoring with Prometheus and Grafana for observability
- Configure Caddy WAF with ModSecurity integration for web application protection
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Configuration
DOMAIN="${1:-}"
DNS_PROVIDER="${2:-cloudflare}"
API_TOKEN="${3:-}"
# Usage function
usage() {
echo "Usage: $0 <domain> [dns_provider] [api_token]"
echo "Example: $0 example.com cloudflare cf_token_here"
echo "Supported DNS providers: cloudflare, route53, digitalocean, namecheap"
exit 1
}
# Logging functions
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Cleanup function
cleanup() {
if [ $? -ne 0 ]; then
log_error "Installation failed. Cleaning up..."
systemctl stop caddy 2>/dev/null || true
systemctl disable caddy 2>/dev/null || true
rm -f /etc/caddy/Caddyfile /etc/caddy/caddy.env 2>/dev/null || true
fi
}
trap cleanup EXIT
# Validate arguments
if [ -z "$DOMAIN" ]; then
usage
fi
# Check if running as root or with sudo
if [ $EUID -ne 0 ]; then
log_error "This script must be run as root or with sudo"
exit 1
fi
# Auto-detect distribution
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_UPDATE="apt update && apt upgrade -y"
PKG_INSTALL="apt install -y"
REPO_SETUP="debian"
;;
almalinux|rocky|centos|rhel|ol|fedora)
PKG_MGR="dnf"
PKG_UPDATE="dnf update -y"
PKG_INSTALL="dnf install -y"
REPO_SETUP="rhel"
;;
amzn)
PKG_MGR="yum"
PKG_UPDATE="yum update -y"
PKG_INSTALL="yum install -y"
REPO_SETUP="rhel"
;;
*)
log_error "Unsupported distribution: $ID"
exit 1
;;
esac
else
log_error "Cannot detect distribution"
exit 1
fi
log_info "Detected distribution: $ID"
# Step 1: Update system packages
echo "[1/10] Updating system packages..."
$PKG_UPDATE
# Step 2: Install required packages
echo "[2/10] Installing required packages..."
$PKG_INSTALL curl wget gnupg
# Install distribution-specific packages
if [ "$REPO_SETUP" = "debian" ]; then
$PKG_INSTALL lsb-release
else
if ! command -v dnf >/dev/null 2>&1 && [ "$PKG_MGR" = "dnf" ]; then
$PKG_INSTALL 'dnf-command(copr)'
fi
fi
# Step 3: Add Caddy repository
echo "[3/10] Adding Caddy repository..."
if [ "$REPO_SETUP" = "debian" ]; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update
else
if [ "$PKG_MGR" = "dnf" ]; then
dnf copr enable @caddy/caddy -y
else
yum install -y yum-plugin-copr
yum copr enable @caddy/caddy -y
fi
fi
# Step 4: Install Caddy
echo "[4/10] Installing Caddy..."
$PKG_INSTALL caddy
# Step 5: Create caddy user if it doesn't exist
echo "[5/10] Configuring Caddy user..."
if ! id -u caddy >/dev/null 2>&1; then
useradd --system --home /var/lib/caddy --create-home --shell /usr/sbin/nologin caddy
fi
# Step 6: Create directory structure
echo "[6/10] Creating directory structure..."
mkdir -p /etc/caddy /var/www/html /var/log/caddy /var/lib/caddy
chown -R caddy:caddy /etc/caddy /var/www/html /var/log/caddy /var/lib/caddy
chmod 755 /etc/caddy /var/www/html /var/log/caddy /var/lib/caddy
# Step 7: Create environment file
echo "[7/10] Creating environment configuration..."
cat > /etc/caddy/caddy.env << EOF
# DNS Provider Configuration
# Replace with your actual API credentials
EOF
case "$DNS_PROVIDER" in
cloudflare)
echo "CLOUDFLARE_API_TOKEN=${API_TOKEN:-your_cloudflare_api_token_here}" >> /etc/caddy/caddy.env
;;
route53)
cat >> /etc/caddy/caddy.env << EOF
ROUTE53_ACCESS_KEY_ID=${API_TOKEN:-your_access_key_id}
ROUTE53_SECRET_ACCESS_KEY=${API_TOKEN:-your_secret_access_key}
ROUTE53_REGION=us-east-1
EOF
;;
digitalocean)
echo "DIGITALOCEAN_TOKEN=${API_TOKEN:-your_do_token}" >> /etc/caddy/caddy.env
;;
namecheap)
cat >> /etc/caddy/caddy.env << EOF
NAMECHEAP_API_USER=${API_TOKEN:-your_username}
NAMECHEAP_API_KEY=${API_TOKEN:-your_api_key}
EOF
;;
esac
chown caddy:caddy /etc/caddy/caddy.env
chmod 600 /etc/caddy/caddy.env
# Step 8: Create Caddyfile
echo "[8/10] Creating Caddy configuration..."
cat > /etc/caddy/Caddyfile << EOF
{
email admin@${DOMAIN}
acme_ca https://acme-v02.api.letsencrypt.org/directory
}
${DOMAIN} {
tls {
dns ${DNS_PROVIDER} {env.$(echo $DNS_PROVIDER | tr '[:lower:]' '[:upper:]')_API_TOKEN}
}
root * /var/www/html
file_server
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/access.log
format json
}
}
*.${DOMAIN} {
tls {
dns ${DNS_PROVIDER} {env.$(echo $DNS_PROVIDER | tr '[:lower:]' '[:upper:]')_API_TOKEN}
}
handle {
respond "Subdomain not configured" 404
}
}
EOF
chown caddy:caddy /etc/caddy/Caddyfile
chmod 644 /etc/caddy/Caddyfile
# Step 9: Configure systemd service
echo "[9/10] Configuring systemd service..."
mkdir -p /etc/systemd/system/caddy.service.d
cat > /etc/systemd/system/caddy.service.d/override.conf << EOF
[Service]
EnvironmentFile=/etc/caddy/caddy.env
EOF
# Create sample index page
cat > /var/www/html/index.html << EOF
<!DOCTYPE html>
<html>
<head>
<title>Caddy with Let's Encrypt</title>
</head>
<body>
<h1>Success!</h1>
<p>Caddy is running with automatic SSL certificates for ${DOMAIN}</p>
</body>
</html>
EOF
chown caddy:caddy /var/www/html/index.html
chmod 644 /var/www/html/index.html
# Configure firewall if available
if command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --permanent --add-service=http --add-service=https 2>/dev/null || true
firewall-cmd --reload 2>/dev/null || true
elif command -v ufw >/dev/null 2>&1; then
ufw allow 80/tcp 2>/dev/null || true
ufw allow 443/tcp 2>/dev/null || true
fi
# Configure SELinux if enabled
if command -v setsebool >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" = "Enforcing" ]; then
setsebool -P httpd_can_network_connect 1 2>/dev/null || true
fi
systemctl daemon-reload
systemctl enable caddy
systemctl start caddy
# Step 10: Verification
echo "[10/10] Verifying installation..."
sleep 3
if systemctl is-active --quiet caddy; then
log_info "Caddy service is running"
else
log_error "Caddy service failed to start"
systemctl status caddy --no-pager
exit 1
fi
# Test configuration
if caddy validate --config /etc/caddy/Caddyfile; then
log_info "Caddy configuration is valid"
else
log_error "Caddy configuration validation failed"
exit 1
fi
log_info "Installation completed successfully!"
log_warn "IMPORTANT: Update the API credentials in /etc/caddy/caddy.env"
log_warn "Then restart Caddy: systemctl restart caddy"
echo ""
echo "Configuration files:"
echo " - Caddyfile: /etc/caddy/Caddyfile"
echo " - Environment: /etc/caddy/caddy.env (update your API credentials here)"
echo " - Web root: /var/www/html"
echo " - Logs: /var/log/caddy/"
echo ""
echo "Commands:"
echo " - Check status: systemctl status caddy"
echo " - View logs: journalctl -u caddy -f"
echo " - Restart service: systemctl restart caddy"
Review the script before running. Execute with: bash install.sh