Last updated: March 22, 2026

Caddy is the easiest way to put HTTPS in front of every internal tool your team runs — Grafana, Kibana, Gitea, a private Retool instance, whatever. It auto-provisions TLS from Let’s Encrypt, reloads config without dropping connections, and ships a dead-simple declarative config format. This guide walks through a production-ready Caddy setup for a distributed team.

Why Caddy Over nginx or Traefik

nginx requires manual cert renewal via cron + certbot. Traefik is great but demands a running Docker daemon and a non-trivial label schema. Caddy gives you automatic HTTPS with zero cron jobs, a single Caddyfile, and a JSON API for dynamic updates — ideal for small platform teams.

Caddy’s hard advantages:

Install Caddy

# Debian/Ubuntu
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

# macOS (Homebrew)
brew install caddy

# Binary download (any Linux)
curl -L "https://github.com/caddyserver/caddy/releases/latest/download/caddy_linux_amd64.tar.gz" \
  | tar -xz caddy
sudo mv caddy /usr/local/bin/
sudo setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/caddy

Verify:

caddy version
# v2.9.1 h1:...

Directory Layout

/etc/caddy/
├── Caddyfile          # main config
├── snippets/
│   ├── auth.caddy     # shared auth block
│   └── headers.caddy  # shared security headers
└── tls/
    ├── cert.pem       # optional manual cert
    └── key.pem

Create directories:

sudo mkdir -p /etc/caddy/snippets /etc/caddy/tls
sudo chown -R caddy:caddy /etc/caddy

Basic Caddyfile for Internal Tools

# /etc/caddy/Caddyfile

{
    # Global options
    admin 127.0.0.1:2019        # JSON API — never expose publicly
    log {
        level  INFO
        format json
    }
    # Use internal CA for .internal domains
    local_certs
}

# Grafana
grafana.internal.example.com {
    reverse_proxy localhost:3000
    import snippets/auth.caddy
    import snippets/headers.caddy
    tls internal
}

# Kibana
kibana.internal.example.com {
    reverse_proxy localhost:5601
    import snippets/auth.caddy
    import snippets/headers.caddy
    tls internal
}

# Gitea
git.internal.example.com {
    reverse_proxy localhost:3001 {
        header_up X-Forwarded-Proto {scheme}
        header_up X-Real-IP {remote_host}
    }
    import snippets/headers.caddy
    tls internal
}

# Retool / internal app
app.internal.example.com {
    reverse_proxy localhost:3002
    import snippets/auth.caddy
    import snippets/headers.caddy
    tls internal
}

auth.caddy Snippet

# /etc/caddy/snippets/auth.caddy
# Protects any site with HTTP basic auth
basicauth {
    # Generate hash: caddy hash-password --plaintext "your-password"
    ops       $2a$14$K6GzFsRqZZqBxl3GNXR8i.D7ZW9IuJLiNFwqPCl5w0xE3h3M4pBfG
    devteam   $2a$14$T9wAz8kXVs1N2mLqY3dBjuR5HqPu4nZ1cF7bEeRdSm0vW8kP3oAqC
}

Generate a new hash:

caddy hash-password --plaintext "your-secure-password"

headers.caddy Snippet

# /etc/caddy/snippets/headers.caddy
header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options    "nosniff"
    X-Frame-Options           "SAMEORIGIN"
    Referrer-Policy           "strict-origin-when-cross-origin"
    -Server
    -X-Powered-By
}

Wildcard TLS With DNS Challenge

For a real domain using AWS Route 53:

# Install the Route53 DNS plugin
xcaddy build --with github.com/caddy-dns/route53
{
    admin 127.0.0.1:2019
    acme_dns route53 {
        access_key_id     {$AWS_ACCESS_KEY_ID}
        secret_access_key {$AWS_SECRET_ACCESS_KEY}
        region            us-east-1
    }
}

*.internal.example.com {
    tls {
        dns route53
    }

    @grafana  host grafana.internal.example.com
    @kibana   host kibana.internal.example.com
    @git      host git.internal.example.com

    handle @grafana {
        reverse_proxy localhost:3000
        import snippets/auth.caddy
    }
    handle @kibana {
        reverse_proxy localhost:5601
        import snippets/auth.caddy
    }
    handle @git {
        reverse_proxy localhost:3001
    }

    import snippets/headers.caddy
}

Systemd Service

If you installed from the apt repo, systemd is already configured. For a binary install:

# /etc/systemd/system/caddy.service
sudo tee /etc/systemd/system/caddy.service > /dev/null <<'EOF'
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now caddy

Zero-Downtime Config Reload

# Validate config before reloading
caddy validate --config /etc/caddy/Caddyfile

# Reload running instance (no dropped connections)
sudo systemctl reload caddy

# Or use the API directly
curl -X POST "http://127.0.0.1:2019/load" \
  -H "Content-Type: text/caddyfile" \
  --data-binary @/etc/caddy/Caddyfile

Rate Limiting and IP Allow Lists

grafana.internal.example.com {
    # Only allow VPN subnet
    @allowed remote_ip 10.8.0.0/16 192.168.1.0/24
    handle @allowed {
        reverse_proxy localhost:3000
    }
    respond "Forbidden" 403

    # Rate limit: 100 requests/minute per IP
    rate_limit {remote_host} 100r/m
}

OAuth2 Proxy Integration

For SSO via GitHub/Google, run oauth2-proxy alongside Caddy:

grafana.internal.example.com {
    # Forward auth to oauth2-proxy
    forward_auth localhost:4180 {
        uri /oauth2/auth
        copy_headers X-Auth-Request-User X-Auth-Request-Email
    }
    reverse_proxy localhost:3000
    import snippets/headers.caddy
    tls internal
}

Start oauth2-proxy:

oauth2-proxy \
  --provider=github \
  --client-id="${GITHUB_CLIENT_ID}" \
  --client-secret="${GITHUB_CLIENT_SECRET}" \
  --github-org=your-org \
  --cookie-secret="$(openssl rand -base64 32)" \
  --email-domain="*" \
  --upstream=http://localhost:3000 \
  --http-address=127.0.0.1:4180

Logging to a File

grafana.internal.example.com {
    log {
        output file /var/log/caddy/grafana-access.log {
            roll_size     100MiB
            roll_keep     10
            roll_keep_for 720h
        }
        format json
        level  INFO
    }
    reverse_proxy localhost:3000
}

Health Check Endpoint

:2020 {
    respond /health 200
}

Verify from monitoring:

curl -sf http://localhost:2020/health && echo "Caddy UP"

Troubleshooting

Problem Check
permission denied binding port 80/443 sudo setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/caddy
TLS cert not provisioned journalctl -u caddy -f — look for ACME errors
502 Bad Gateway Is the upstream actually running on the expected port?
Config reload fails caddy validate first — parse errors are printed clearly
tls internal not trusted by browser Install Caddy’s local CA: caddy trust

Install the local CA on macOS clients so browsers trust .internal certs:

# On the Caddy server
sudo caddy trust

# Copy the root CA to clients
scp caddy-host:/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt ~/caddy-local.crt

# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/caddy-local.crt

Built by theluckystrike — More at zovo.one