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

Related Reading


Built by theluckystrike. More at zovo.one