Caddy is a web server written in Go that automatically provisions and renews TLS certificates from Let’s Encrypt. There is no certbot, no cron jobs, no manual renewal — Caddy handles it all. This makes it the fastest way to get a self-hosted service behind HTTPS on a VPS.
Prerequisites
- A VPS with a public IP
- A domain pointing at your VPS (A record)
- Ports 80 and 443 open (Caddy needs both for ACME HTTP-01 challenge)
- Ubuntu 20.04 or later
Step 1: Install Caddy
# Install from the official Caddy repository
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
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 update && sudo apt install caddy
# Verify
caddy version
# v2.8.x
Caddy installs as a systemd service and starts listening on port 80 immediately. It serves a placeholder page until you configure your Caddyfile.
Step 2: Basic Caddyfile
The Caddyfile lives at /etc/caddy/Caddyfile. Edit it:
sudo nano /etc/caddy/Caddyfile
Minimal configuration to serve a static site with automatic HTTPS:
example.com {
root * /var/www/html
file_server
}
That is the entire configuration. Caddy:
- Detects that
example.comis a real domain - Provisions a Let’s Encrypt certificate automatically
- Serves files from
/var/www/html - Redirects HTTP to HTTPS automatically
Apply the configuration:
sudo systemctl reload caddy
sudo systemctl status caddy
Check certificate:
curl -vI https://example.com 2>&1 | grep -A2 "SSL certificate\|subject:\|issuer:"
Step 3: Reverse Proxy to a Backend
The most common use case — proxying to an application running on localhost:
example.com {
reverse_proxy localhost:8080
}
api.example.com {
reverse_proxy localhost:3000
}
Caddy forwards all headers and handles TLS termination. The backend only needs to listen on a local port.
For multiple services on subdomains:
# Wildcard cert (requires DNS challenge — see below)
*.example.com {
@wiki host wiki.example.com
@gitea host gitea.example.com
@grafana host grafana.example.com
handle @wiki {
reverse_proxy localhost:3001
}
handle @gitea {
reverse_proxy localhost:3000
}
handle @grafana {
reverse_proxy localhost:3002
}
}
Step 4: Security Headers
Add security headers globally using the header directive:
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
-Server
-X-Powered-By
}
}
example.com {
import security_headers
reverse_proxy localhost:8080
}
The -Server and -X-Powered-By lines remove those response headers entirely to reduce fingerprinting.
Step 5: Rate Limiting
Caddy’s rate limit module is not built-in but available as a plugin. For basic protection, use the respond directive to block excessive requests, or deploy the caddy-ratelimit plugin:
# Build caddy with rate limit module
xcaddy build --with github.com/mholt/caddy-ratelimit
sudo mv caddy /usr/bin/caddy
sudo systemctl restart caddy
Then in your Caddyfile:
example.com {
rate_limit {
zone dynamic {
key {remote_host}
events 100
window 1m
}
}
reverse_proxy localhost:8080
}
Step 6: Basic Authentication
Protect an internal service with an username/password:
# Generate a bcrypt-hashed password
caddy hash-password --plaintext 'your-strong-password'
# $2a$14$...
private.example.com {
basicauth {
alice $2a$14$...hashedpassword...
}
reverse_proxy localhost:8081
}
Step 7: Logging
Enable structured JSON access logs for security analysis:
example.com {
log {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 5
}
format json
level INFO
}
reverse_proxy localhost:8080
}
Create the log directory:
sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy
Parse logs:
# Top 10 IPs by request count
cat /var/log/caddy/access.log | jq -r '.request.remote_ip' | sort | uniq -c | sort -rn | head
# Show 404s
cat /var/log/caddy/access.log | jq 'select(.status == 404) | {uri: .request.uri, ip: .request.remote_ip}'
Step 8: DNS Challenge for Wildcard Certificates
Wildcard certs (*.example.com) require DNS-01 challenge, which means Caddy needs API access to your DNS provider. Install the appropriate DNS module:
# For Cloudflare
xcaddy build --with github.com/caddy-dns/cloudflare
# In Caddyfile, provide the API token
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# ... your handlers
}
Set the environment variable:
# /etc/systemd/system/caddy.service.d/override.conf
[Service]
Environment="CF_API_TOKEN=your_cloudflare_api_token"
sudo systemctl daemon-reload
sudo systemctl restart caddy
Verify Everything
# Check certificate status
sudo caddy certificates
# Validate your Caddyfile without restarting
caddy validate --config /etc/caddy/Caddyfile
# Test TLS configuration
curl -vI https://example.com
# Grade your TLS at Qualys SSL Labs
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com
Related Reading
- How to Configure UFW Firewall on Ubuntu
- Secure WebSocket Connections Setup Guide
- How To Set Up Automatic Account Deletion Triggers If You
- AI Coding Assistant Session Data Lifecycle
-
How to Audit What Source Code AI Coding Tools Transmit