॥ श्री ॥

Caddy

Networking 2026-08-28

Caddy is a modern web server with automatic HTTPS. It provisions and renews TLS certificates entirely automatically — via Let's Encrypt for public domains and via its own internal CA for private .home.local addresses — with no manual certificate management, no OpenSSL commands, and no browser warnings.

On Shani OS, Caddy is pre-installed and is the recommended reverse proxy for all self-hosted services. Always bind container ports to 127.0.0.1 and proxy through Caddy — this ensures every service is accessible only via HTTPS and never directly exposed to the network.


Configuration Context

Caddy's behaviour depends on how it is launched.

When managed via systemd, Caddy runs as the caddy system user and loads a system-wide config:

  • Config file: /etc/caddy/Caddyfile
  • Certificates & data: /var/lib/caddy/.local/share/caddy/

On Shani OS, /etc is managed atomically — your Caddyfile persists across OS updates and rollbacks.

sudo systemctl enable --now caddy

Manual / Development

Running caddy run from a terminal loads the Caddyfile from the current working directory, or from an explicit path:

cd ~/projects/my-app/
caddy run                              # loads ./Caddyfile
caddy run --config /path/to/Caddyfile  # explicit path

Service Management

# Enable and start on boot
sudo systemctl enable --now caddy

# Reload config with zero downtime (preferred — no connection drops)
sudo systemctl reload caddy

# Full restart (needed after a Caddy binary update)
sudo systemctl restart caddy

# Check status
systemctl status caddy

# Watch live logs
journalctl -u caddy -f

# Validate Caddyfile syntax before reloading (catches errors before they go live)
caddy validate --config /etc/caddy/Caddyfile

# Auto-format and fix indentation in place
sudo caddy fmt --overwrite /etc/caddy/Caddyfile

Caddyfile Reference

Reverse Proxy

The most common use case — route external traffic to local services running on Podman, Distrobox, or bare ports.

# /etc/caddy/Caddyfile

# Public domain — HTTPS via Let's Encrypt, auto-renewed
app.example.com {
    reverse_proxy localhost:8080
}

# Private domain — HTTPS via Caddy's internal CA
# Trust the CA once (see TLS section below) and all .home.local sites get valid certs
myapp.home.local {
    tls internal
    reverse_proxy localhost:3000
}

# Multiple subdomains, different backends
api.example.com       { reverse_proxy localhost:8000 }
dashboard.example.com { reverse_proxy localhost:9090 }
grafana.home.local    { tls internal; reverse_proxy localhost:3001 }

Static File Server

docs.example.com {
    root * /srv/http/docs
    file_server
    encode zstd gzip
}

Load Balancing

app.example.com {
    reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
        lb_policy round_robin
        health_uri /health
        health_interval 10s
    }
}

Basic Auth & Security Headers

# Generate the bcrypt hash first:
#   caddy hash-password --plaintext "yourpassword"

secure.example.com {
    basicauth {
        admin $2a$14$YOUR_HASH_HERE
    }
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy no-referrer
        -Server
    }
    reverse_proxy localhost:8080
}

Forward Auth (Authelia / Authentik)

Protect any service behind your SSO provider — one login page guards your entire self-hosted stack:

# Authelia forward auth
service.example.com {
    forward_auth localhost:9091 {
        uri /api/verify?rd=https://auth.example.com
        copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
    }
    reverse_proxy localhost:SERVICE_PORT
}

# Authentik forward auth
service.example.com {
    forward_auth localhost:9000 {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-authentik-username X-authentik-groups X-authentik-email
        header_up X-Original-URL {scheme}://{host}{uri}
    }
    reverse_proxy localhost:SERVICE_PORT
}

Redirect HTTP → HTTPS (Explicit)

Caddy handles this automatically for public domains. For internal hosts where you want an explicit redirect:

http://myapp.home.local {
    redir https://{host}{uri} permanent
}

Global Options

{
    email admin@example.com     # Used for Let's Encrypt notifications
    default_bind 0.0.0.0        # Prefer IPv4; remove if IPv6 works correctly
    # admin off                 # Disable the local admin API if not needed
}

example.com {
    reverse_proxy 192.168.1.50:80
}

TLS & Certificates

Domain typeConfigurationHow it works
Public (example.com)No tls directive neededCaddy auto-provisions via Let's Encrypt ACME (HTTP-01 or TLS-ALPN-01)
Private (.home.local, .lan, .internal)tls internalCaddy issues certificates from its own local CA
Custom certtls /path/cert.pem /path/key.pemYour own certificate
Let's Encrypt staging (testing)acme_ca https://acme-staging-v02.api.letsencrypt.org/directoryAvoids rate limits while testing

Trust the Internal CA

Run this once on the Shani OS machine so that browsers and command-line tools trust the local CA for all *.home.local domains:

sudo trust anchor \
  /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
sudo update-ca-trust

To trust it on other devices (phones, other laptops), export the certificate and install it in each device's trust store. On Android: Settings → Security → Install from storage. On iOS: Settings → General → VPN & Device Management.

Wildcard Certificates (DNS-01 Challenge)

For a wildcard cert (*.home.local) that works across all subdomains, use the DNS-01 challenge with your DNS provider's plugin. This avoids needing port 80 open:

{
    acme_dns cloudflare {env.CF_API_TOKEN}
}

*.home.local {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    @jellyfin host jellyfin.home.local
    handle @jellyfin {
        reverse_proxy localhost:8096
    }
    @nextcloud host nextcloud.home.local
    handle @nextcloud {
        reverse_proxy localhost:8080
    }
}

Caddy + Tailscale

Use Caddy with tls internal to serve HTTPS on your Tailscale interface. Services become available as https://hostname.tailnet-name.ts.net with valid certificates on every tailnet device (after trusting the local CA):

# Accessible only on your tailnet — not exposed to the internet
jellyfin.home.local {
    tls internal
    reverse_proxy localhost:8096
}

vaultwarden.home.local {
    tls internal
    reverse_proxy localhost:8180
}

Services exposed via Tailscale do not need firewall rules — Tailscale handles access control. See the Tailscale wiki page for setup.


Caddy + Cloudflared

When exposing services via Cloudflare Tunnel, you do not need a Caddyfile entry — Cloudflare handles TLS termination at the edge. Caddy is still the right choice for internal .home.local access on Tailscale, and for any services accessed directly on the LAN.

Use both together: Caddy serves internal traffic; Cloudflared exposes selected services publicly. See the Cloudflared wiki page.


Permissions & File Serving

Caddy runs as the caddy system user with restricted privileges. If it needs to read files from your home directory, grant access with ACLs rather than changing ownership:

# Grant recursive read + execute to the caddy user
sudo setfacl -R -m u:caddy:rx /home/user/www

# Verify
getfacl /home/user/www

For SELinux contexts (Shani OS uses SELinux by default):

# Check the current context
ls -Z /home/user/www

# Relabel if needed
sudo restorecon -Rv /home/user/www

Firewall

Open ports 80 and 443 if Caddy is serving public traffic. Let's Encrypt requires port 80 to be reachable for HTTP-01 challenges:

sudo firewall-cmd --add-service=http --add-service=https --permanent
sudo firewall-cmd --reload

For internal-only Caddy serving .home.local addresses over Tailscale or the LAN, no firewall changes are needed — ports 80 and 443 do not need to be open to the internet.


Troubleshooting

IssueSolution
permission denied serving filessudo setfacl -R -m u:caddy:rx /your/path; check SELinux context with ls -Z
Browser shows untrusted cert for .home.localTrust the Caddy local CA: sudo trust anchor /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt && sudo update-ca-trust
bind: address already in useAnother process owns port 80 or 443 — `sudo ss -tlnpgrep ':80\:443'` to identify it
Service won't startValidate syntax first: caddy validate --config /etc/caddy/Caddyfile; then check logs: journalctl -u caddy -f
Let's Encrypt rate limit hitSwitch to staging CA: acme_ca https://acme-staging-v02.api.letsencrypt.org/directory while testing
Reverse proxy returns 502 Bad GatewayConfirm the backend is running and bound to the right port: curl -v http://localhost:PORT; for containers use host.containers.internal instead of localhost
Config change not taking effectRun sudo systemctl reload caddy — Caddy must be explicitly reloaded to pick up Caddyfile changes
HTTPS certificate not renewingCheck journalctl -u caddy for ACME errors; confirm port 80 is reachable from the internet for HTTP-01 challenges
WebSocket connections droppingAdd @ws { header Connection Upgrade } matcher and handle @ws { reverse_proxy ... } with explicit WebSocket header passthrough

See Also