Install Caddy Reverse Proxy on Ubuntu 26.04

– min read

Once your homelab runs more than a few self-hosted services, juggling nginx configs and certbot cron for each subdomain gets old fast. I put Caddy in front of Grafana and Prometheus, and the whole proxy setup became a single 30-line config file.

Official site: caddyserver.com
GitHub: github.com/caddyserver/caddy


Caddy web server banner logo
Caddy logo (public domain, Wikimedia Commons)


What makes Caddy different

A reverse proxy needs TLS, routing, logging, and sane defaults. Caddy delivers all four with a config format that avoids fifteen nginx server blocks.

Capabilitynginx + certbotCaddy
TLS certscertbot cron + reload hooktls internal (no extra tool)
HTTP/3extra listen + compileon by default (alt-svc)
Admin APIreload or nginx -slocalhost:2019 live JSON
Access logsaccess_log directivelog { output file /path }


Requirements

  1. Ubuntu 26.04 (24.04 works too)
  2. Root or sudo access
  3. Ports 80 and 443 free


Step 1: Add the official Caddy repository

Ubuntu 26.04 has Caddy 2.6.2; upstream has 2.11.9. Install from Cloudsmith:

# Download key and verify
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key -o /tmp/caddy.gpg.key
sha256sum /tmp/caddy.gpg.key
# Expected: 783dfee0... 

# Add repo
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg.key
echo "deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main" | sudo sh -c "cat > /etc/apt/sources.list.d/caddy-stable.list"
sudo apt-get update && apt-cache policy caddy
# Real output
caddy:
  Installed: (none)
  Candidate: 2.11.9
  Version table:
     2.11.9 500


Step 2: Install and verify

Install and check:

sudo apt-get install -y caddy
caddy version
# Output
v2.11.9 h1:bhZtcRKQOq0fuLvEgCdPYvUxXyGn9kzjG4lh2332DTE=

Caddy enables a systemd service by default:

# systemctl status caddy --no-pager
# ● caddy.service - Caddy
#      Loaded: loaded (/usr/lib/systemd/system/caddy.service; enabled)
#      Active: active (running) since Fri 2026-11-06 03:01:46 UTC
#        Docs: https://caddyserver.com/docs/
#    Main PID: 232110 (caddy)
#       Tasks: 8  Memory: 9.6M


Homelab reverse proxy topology diagram
Topology drawn for this post


Step 3: Write the Caddyfile

This single file holds every site. Two backends: Grafana on 8081 and Prometheus on 8082.

# /etc/caddy/Caddyfile
{
	admin localhost:2019
}

grafana.lab.example {
	tls internal
	log { output file /var/log/caddy/grafana-access.log }
	reverse_proxy 127.0.0.1:8081 {
		health_uri /
		health_interval 10s
		fail_duration 30s
	}
}

home.lab.example {
	tls internal
	handle /prometheus/* {
		uri strip_prefix /prometheus
		reverse_proxy 127.0.0.1:8082
	}
	handle {
		root * /srv/www
		encode gzip zstd
		file_server
	}
}
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# Valid configuration


Step 4: Reload and test

Reload and use curl with –resolve to override DNS for .lab.example (demo-only) domains:

sudo systemctl reload caddy
systemctl is-active caddy
# active
# HTTPS reverse proxy
curl -sk --resolve grafana.lab.example:443:127.0.0.1 https://grafana.lab.example/
# Grafana placeholder backend :8081

# Path routing
curl -sk --resolve home.lab.example:443:127.0.0.1 https://home.lab.example/prometheus/
# Prometheus placeholder backend :8082

# Static file
curl -sk --resolve home.lab.example:443:127.0.0.1 https://home.lab.example/
# <h1>Static site served by Caddy</h1>


Step 5: Trust the internal CA

Caddy’s local CA needs its root cert in the trust store so browsers and curl work without -k:

sudo cp /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt /usr/local/share/ca-certificates/caddy-local-authority.crt
sudo update-ca-certificates
curl -s --resolve grafana.lab.example:443:127.0.0.1 https://grafana.lab.example/
# Grafana placeholder backend :8081
echo | openssl s_client -connect 127.0.0.1:443 -servername grafana.lab.example 2>/dev/null | openssl x509 -noout -issuer -dates
# issuer=CN=Caddy Local Authority - ECC Intermediate
# notBefore=Nov  6 03:03:20 2026 GMT
# notAfter=Nov  6 15:03:20 2026 GMT


Step 6: Access logs and admin API

Per-site logs are structured JSON. Summarise the last request:

tail -1 /var/log/caddy/grafana-access.log | python3 -c 'import json,sys;d=json.loads(sys.stdin.read());print(d["status"],d["request"]["proto"],d["request"]["host"]+d["request"]["uri"],"%.1fms"%(d["duration"]*1000),d["size"],"bytes")'
# 200 HTTP/2.0 grafana.lab.example/ 1.1ms 34 bytes

The admin API on localhost:2019 exposes live config:

curl -s localhost:2019/config/apps/http/servers/srv0/routes/0/handle/0/ | python3 -m json.tool | head -20


Step 7: Format before reloading

# Preview then apply
caddy fmt /etc/caddy/Caddyfile --diff
caddy fmt /etc/caddy/Caddyfile --overwrite


Wrap up

Caddy replaced my collection of nginx blocks, certbot timers, and shell scripts with a single file and zero extra daemons. The setup runs on the same machine as my Prometheus and Grafana stack, my Uptime Kuma dashboard, and my Cockpit web console. All commands tested on Ubuntu 26.04 with synthetic backends (RFC 5737 loopback addresses).

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page