Free SSL with Let's Encrypt and Caddy: Complete Setup Guide for Self-Hosted Apps
Set up automatic HTTPS for your self-hosted apps using Caddy and Let's Encrypt — zero manual certificate management. Covers reverse proxy, wildcard certs, internal networks, and real production configs.
Why Caddy + Let's Encrypt?
For years, setting up HTTPS meant: generating CSRs, submitting to a CA, downloading certificates, configuring Nginx or Apache, setting up cron jobs for renewal, debugging permission errors. Every 90 days.
Caddy eliminates all of that. It automatically obtains certificates from Let's Encrypt, handles renewal, redirects HTTP to HTTPS, and does it all from a single readable config file.
I've migrated most of our self-hosted infrastructure — dashboards, internal tools, APIs — to Caddy. The reduction in SSL-related maintenance work is significant.
What is Let's Encrypt?
Let's Encrypt is a free, automated Certificate Authority (CA) run by the Internet Security Research Group (ISRG). It issues Domain Validation (DV) TLS certificates at no cost via the ACME protocol (Automated Certificate Management Environment — RFC 8555).
Key facts:
- Free — no cost for any number of certificates
- Automated — certificate issuance and renewal via ACME
- 90-day validity — short lifetime forces automation, improves security
- Rate limits — 50 certificates per domain per week (plenty for most use cases)
- Trusted — root certificates in all major browsers and OS trust stores
What is Caddy?
Caddy is a web server written in Go that has automatic HTTPS built in as a first-class feature — not a plugin or add-on. When you configure a site with a public domain, Caddy automatically:
- Generates a private key
- Requests a certificate from Let's Encrypt via ACME
- Serves HTTPS immediately
- Renews the certificate before it expires (no cron job needed)
- Redirects all HTTP traffic to HTTPS
The config format (Caddyfile) is also much simpler than Nginx.
Installation
Install Caddy on Ubuntu/Debian
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 -y caddy
# Verify
caddy version
# caddy v2.9.x ...
# Enable and start
sudo systemctl enable --now caddy
sudo systemctl status caddyInstall on Raspberry Pi (ARM)
The same apt commands work on Pi OS. Caddy has native ARM64 builds.
Install on Other Linux (Direct Binary)
curl -fsSL https://caddyserver.com/api/download?os=linux&arch=amd64 -o caddy
sudo mv caddy /usr/local/bin/
sudo chmod +x /usr/local/bin/caddy
sudo setcap cap_net_bind_service=+ep /usr/local/bin/caddy # allow binding port 80/443 without rootThe Caddyfile — Syntax Basics
The Caddyfile lives at /etc/caddy/Caddyfile. Its syntax is:
<address> {
directive value
directive value
}
That's it. Compare to 40+ lines of Nginx SSL configuration for the same result.
Basic HTTPS Site
Static Website
# /etc/caddy/Caddyfile
techsystemlab.com {
root * /var/www/techsystemlab
file_server
}That's the entire config. Caddy will:
- Obtain a Let's Encrypt certificate for
techsystemlab.com - Serve files from
/var/www/techsystemlab - Redirect HTTP to HTTPS automatically
- Renew the certificate automatically
sudo systemctl reload caddyWith www Redirect
www.techsystemlab.com {
redir https://techsystemlab.com{uri} permanent
}
techsystemlab.com {
root * /var/www/techsystemlab
file_server
}Reverse Proxy — The Most Common Use Case
The most valuable use of Caddy is as a reverse proxy — putting Caddy in front of apps running on localhost ports and giving them HTTPS.
Single App
dashboard.techsystemlab.com {
reverse_proxy localhost:8077
}Caddy gets the certificate, handles HTTPS, and forwards requests to your app on port 8077. Your app doesn't need to know anything about SSL.
Multiple Apps on the Same Server
# Network monitoring dashboard
monitor.techsystemlab.com {
reverse_proxy localhost:3000
}
# Grafana
grafana.techsystemlab.com {
reverse_proxy localhost:3001
}
# Internal API
api.techsystemlab.com {
reverse_proxy localhost:8080
# Add CORS headers for API
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
}
# InfluxDB (restrict to VPN/internal only)
influx.techsystemlab.com {
reverse_proxy localhost:8086
# Only allow from VPN subnet
@blocked not remote_ip 10.8.0.0/24
abort @blocked
}Reverse Proxy with Path Routing
app.techsystemlab.com {
# API requests go to the backend
handle /api/* {
reverse_proxy localhost:8080
}
# Everything else goes to the frontend
handle {
reverse_proxy localhost:3000
}
}Basic Authentication (Password Protection)
# Generate a bcrypt password hash
caddy hash-password --plaintext 'YourStrongPassword'
# Output: $2a$14$...some-hash...admin.techsystemlab.com {
basicauth {
admin $2a$14$...paste-hash-here...
}
reverse_proxy localhost:8080
}Wildcard Certificates
A wildcard certificate covers *.techsystemlab.com — all subdomains with a single certificate. This is useful when you have many subdomains or create them dynamically.
Wildcard certificates require DNS challenge (not HTTP challenge), because Let's Encrypt needs to verify you control the domain by checking a _acme-challenge TXT record.
Wildcard with Cloudflare DNS (Most Common)
# Install Caddy with Cloudflare DNS plugin
# Option 1: Download from caddyserver.com/download with cloudflare module
# Option 2: Build with xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build --with github.com/caddy-dns/cloudflare
sudo mv caddy /usr/local/bin/caddyGet a Cloudflare API token with Zone:DNS:Edit permission for your domain.
# /etc/caddy/Caddyfile
*.techsystemlab.com, techsystemlab.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
@monitor host monitor.techsystemlab.com
handle @monitor {
reverse_proxy localhost:3000
}
@grafana host grafana.techsystemlab.com
handle @grafana {
reverse_proxy localhost:3001
}
@api host api.techsystemlab.com
handle @api {
reverse_proxy localhost:8080
}
}# Set the API token as environment variable
sudo systemctl edit caddyIn the override file:
[Service]
Environment="CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-here"sudo systemctl daemon-reload && sudo systemctl restart caddyHTTPS for Internal/Private Networks
You might want HTTPS on internal tools that aren't publicly accessible — NMS dashboards, admin panels, internal APIs. Let's Encrypt can't reach your internal server for HTTP challenge, so you need DNS challenge.
Option 1: DNS Challenge (Recommended)
Same as wildcard above — use Cloudflare (or any supported DNS provider) for DNS challenge. Caddy contacts Let's Encrypt, and Let's Encrypt verifies ownership via DNS TXT record. Your server doesn't need to be publicly reachable.
# Works even if nms.internal.techsystemlab.com resolves to a private IP
nms.internal.techsystemlab.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
reverse_proxy localhost:8080
}Option 2: Caddy as Internal CA (Self-Signed, Zero External Dependencies)
For truly air-gapped networks, Caddy can act as its own CA using mkcert or Caddy's built-in internal TLS:
nms.local {
tls internal # Caddy generates its own CA and certificate
reverse_proxy localhost:8080
}You'll need to install Caddy's root CA on your browsers/devices:
# Get Caddy's root CA certificate
caddy trust
# Or find it at:
ls $(caddy environ | grep CADDY_DATA | cut -d= -f2)/pki/authorities/local/
# root.crt — install this on your devices/browsersSecurity Headers and Hardening
techsystemlab.com {
reverse_proxy localhost:3000
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server # remove Server header
}
# Enable gzip compression
encode gzip
# Log access
log {
output file /var/log/caddy/techsystemlab-access.log
format json
}
}Rate Limiting
api.techsystemlab.com {
reverse_proxy localhost:8080
# Rate limit: 10 requests per second per IP
rate_limit {
zone api_zone {
key {remote_host}
events 10
window 1s
}
}
}Note: rate_limit requires the caddy-ratelimit plugin. Install via xcaddy build --with github.com/mholt/caddy-ratelimit.
Useful Caddy Commands
# Test Caddyfile syntax without restarting
caddy validate --config /etc/caddy/Caddyfile
# Reload after config changes (no downtime)
sudo systemctl reload caddy
# or:
caddy reload --config /etc/caddy/Caddyfile
# Check certificate status
caddy list-modules
ls /var/lib/caddy/.local/share/caddy/certificates/
# View logs
journalctl -u caddy -f
# Run Caddy manually (useful for testing)
caddy run --config /etc/caddy/Caddyfile
# Format/tidy Caddyfile
caddy fmt --overwrite /etc/caddy/CaddyfileCaddy vs Nginx for Reverse Proxy
| Feature | Caddy | Nginx | |---------|-------|-------| | Automatic HTTPS | Built-in | Manual (certbot) | | Config simplicity | Very simple | More verbose | | Performance | Excellent | Excellent | | HTTP/3 (QUIC) | Built-in | Requires separate build | | Dynamic config reload | Yes | Yes | | Plugin ecosystem | Growing | Mature | | Memory usage | ~20MB | ~10MB | | Documentation | Good | Excellent |
For new deployments, Caddy wins on simplicity and zero-maintenance SSL. For existing Nginx setups or when you need advanced Nginx features, stick with Nginx + certbot.
Common Issues and Fixes
Certificate not issuing — "ACME server returned error"
# Check Caddy logs for details
journalctl -u caddy --since "10 min ago"
# Common causes:
# 1. Port 80 blocked by firewall — Caddy needs 80 for HTTP challenge
sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
# 2. Domain doesn't resolve to this server's IP
dig +short yourdomain.com
curl ifconfig.me # compare with server's public IP
# 3. CAA record blocking Let's Encrypt
dig CAA yourdomain.com
# If CAA records exist, add: yourdomain.com. CAA 0 issue "letsencrypt.org""Permission denied" binding port 80/443
# Caddy needs CAP_NET_BIND_SERVICE to bind ports < 1024
sudo setcap cap_net_bind_service=+ep $(which caddy)
# Or run as root (not recommended)
# Or change ports in Caddyfile:
:8080 { # HTTP on 8080
...
}Certificate renewal failing silently
# Caddy renews ~30 days before expiry automatically
# Check cert expiry:
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null | \
openssl x509 -noout -dates
# Force renewal (if needed):
caddy reload --config /etc/caddy/CaddyfileComplete Production Example
This is a realistic Caddyfile for a self-hosted monitoring stack — similar to what I run for a client with a remote monitoring setup:
# /etc/caddy/Caddyfile
# Global options
{
email admin@techsystemlab.com # Let's Encrypt expiry notifications
acme_ca https://acme-v02.api.letsencrypt.org/directory
# Optional: use staging CA for testing (no rate limits)
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
# FDS Dashboard — password protected
fds.techsystemlab.com {
basicauth {
fds $2a$14$hashedpasswordhere
}
reverse_proxy localhost:8077
header Strict-Transport-Security "max-age=31536000"
encode gzip
log {
output file /var/log/caddy/fds-access.log
}
}
# Grafana
grafana.techsystemlab.com {
reverse_proxy localhost:3000
header Strict-Transport-Security "max-age=31536000"
encode gzip
}
# InfluxDB API — internal only
influx.techsystemlab.com {
@not_internal not remote_ip 10.8.0.0/24 127.0.0.1
respond @not_internal "Forbidden" 403
reverse_proxy localhost:8086
}
# Redirect bare domain to www
techsystemlab.com {
redir https://www.techsystemlab.com{uri} permanent
}Frequently Asked Questions
Is Let's Encrypt really free? What's the catch?
Let's Encrypt is genuinely free — no catch. It's funded by sponsors (Mozilla, Google, Cisco, EFF and others) and operated as a non-profit. The only limitations are rate limits (50 certs/domain/week) and the 90-day validity period. The short validity is intentional — it forces automation and limits damage if a private key is compromised.
Does Caddy work behind a NAT/router?
Yes, with two conditions: (1) port 80 and 443 must be forwarded from your router to the Caddy server, and (2) your domain must resolve to your public IP. For local-only servers without port forwarding, use the DNS challenge method (Cloudflare) or Caddy's tls internal option.
Can Caddy handle multiple domains with different certificates?
Yes. Each site block in the Caddyfile gets its own certificate. Caddy manages them all independently and renews each one before it expires. There's no limit to the number of domains/certificates Caddy manages.
What happens if Let's Encrypt is down when renewal is due?
Caddy will keep retrying. Let's Encrypt certificates are valid for 90 days, and Caddy starts renewing at 30 days before expiry — giving a 30-day window. In practice, Let's Encrypt has extremely high availability. Caddy also caches certificates on disk, so a temporary outage won't take down your HTTPS.
Can I use Caddy with Cloudflare proxying enabled?
Yes — set Cloudflare proxy to "Proxied" (orange cloud), then in Caddy use the Cloudflare DNS plugin. Your origin certificate will be between Cloudflare and your server. Alternatively, use Cloudflare Origin Certificates (valid for 15 years) as the certificate on Caddy when behind Cloudflare proxy.
Conclusion
Caddy with Let's Encrypt is the easiest way to get production-grade HTTPS on self-hosted infrastructure. The combination of automatic certificate management, simple config syntax, and built-in HTTP/3 support makes it my default choice for new deployments.
The biggest shift in mindset is realising that SSL certificate management is now a solved problem — Caddy handles it, and you never think about it again.
Related: Nginx Reverse Proxy Self-Hosted Apps Guide | Linux Server Hardening Guide | SSH Tunneling and Port Forwarding