Software Engineering WikiSE Wiki

Caddy

Write a Caddyfile that proxies, serves files and issues its own certificates, run Caddy in a container, drive it through the admin API and fix ACME and upstream failures.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Validate a Caddyfilecaddy validate --config /etc/caddy/Caddyfile
Format it in placecaddy fmt --overwrite /etc/caddy/Caddyfile
Show the JSON it becomescaddy adapt --config /etc/caddy/Caddyfile --pretty
Reload with zero downtimecaddy reload --config /etc/caddy/Caddyfile or systemctl reload caddy
Run in the foregroundcaddy run --config /etc/caddy/Caddyfile
Running config as JSONcurl -s localhost:2019/config/ | jq
Upstream healthcurl -s localhost:2019/reverse_proxy/upstreams | jq
Trust the internal CA on this machinecaddy trust
Hash a password for basic_authcaddy hash-password (prompts)
One-off static servercaddy file-server --listen :8080 --root . --browse
One-off reverse proxycaddy reverse-proxy --from :8080 --to localhost:9000
Version and modulescaddy version, caddy list-modules
Build with a DNS pluginxcaddy build --with github.com/caddy-dns/cloudflare
Logs of the systemd servicejournalctl -u caddy -f
Where certificates live (systemd package)ls /var/lib/caddy/.local/share/caddy/certificates/
Test a site without DNScurl -sv --resolve app.example.com:443:127.0.0.1 https://app.example.com/
Check which cert is servedopenssl s_client -connect 127.0.0.1:443 -servername app.example.com </dev/null | openssl x509 -noout -issuer -dates
Stop gracefullycaddy stop

Syntax below is Caddy 2.10 or later; the basic_auth directive was basicauth before 2.8 and the dns global option arrived in 2.10. Reference: the Caddyfile documentation.

How Caddy is configured#

Caddy’s native configuration is JSON, held in memory and changed through the admin API on localhost:2019. A Caddyfile is a config adapter: caddy run reads it, adapts it to JSON and loads that. caddy adapt shows the result, which is the fastest way to see what a directive really did. Directives in a site block are sorted into a fixed order (handle, redir, reverse_proxy, file_server and so on) regardless of how you wrote them; the directive order matters when two directives could both handle a request, and route {} disables the sorting inside its block.

/etc/caddy/Caddyfile                              # the packaged systemd service reads this
/var/lib/caddy/.local/share/caddy/                # $XDG_DATA_HOME/caddy: certificates, keys, internal CA, OCSP
/var/lib/caddy/.config/caddy/autosave.json        # last loaded config, used by caddy run --resume
{
	email admin@example.com                       # ACME account contact; also enables expiry notices
	admin localhost:2019                          # default; "admin off" disables reloads via API
	servers {
		trusted_proxies static 192.0.2.0/24       # believe X-Forwarded-* from these addresses only
	}
	log {
		output file /var/log/caddy/caddy.log
		format json
	}
}

(security) {                                      # snippet: reusable block
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options nosniff
		-Server                                   # remove the header
	}
}

app.example.com {
	import security
	encode zstd gzip
	reverse_proxy 127.0.0.1:8080
}

static.example.com {
	import security
	root * /srv/www/static
	file_server
}

import /etc/caddy/sites/*.caddy                   # one file per site

Site addresses decide the listener and the certificate: example.com means HTTPS on 443 with a public certificate, http://example.com disables TLS for that site, :8080 listens on plain HTTP on every interface, localhost and *.local names get the internal CA. Several addresses share a block with commas. Environment variables are read at adapt time with {$NAME} and at request time with {env.NAME}; use {$NAME} for values in site addresses and {env.NAME} inside handlers.

Automatic HTTPS#

For every site address that looks like a public name, Caddy obtains a certificate from Let’s Encrypt (falling back to ZeroSSL), renews it at two thirds of its lifetime, serves OCSP staples where the CA provides them and redirects HTTP to HTTPS. Challenges are HTTP-01 on port 80 and TLS-ALPN-01 on port 443, tried in turn, so both ports must be reachable from the internet at the public address the name resolves to. Wildcards and names that do not resolve publicly need DNS-01, which needs a DNS plugin.

{
	acme_ca https://acme-staging-v02.api.letsencrypt.org/directory   # stage while testing to avoid rate limits
	# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}                  # default DNS challenge for all sites
}

internal.example.com {
	tls internal                                  # signed by Caddy's own CA; clients must trust its root
	reverse_proxy 127.0.0.1:9090
}

legacy.example.com {
	tls /etc/ssl/legacy.pem /etc/ssl/legacy.key   # bring your own; Caddy then does not manage it
	reverse_proxy 127.0.0.1:9091
}

http://plain.example.com {                        # HTTP only, no redirect, no certificate
	reverse_proxy 127.0.0.1:9092
}

The internal CA is created on first use under pki/authorities/local in the data directory. caddy trust installs its root into the system and browser trust stores of the machine Caddy runs on (it calls the admin API for the certificate, so Caddy must be running); copy root.crt to other machines and install it as described on the TLS page. The intermediate rotates every seven days and the root lasts ten years, so trust the root, never the intermediate.

Certificates and the ACME account key live in the data directory. Losing it means re-issuing every certificate on restart, which walks into the Let’s Encrypt rate limits quickly; back it up and, in containers, mount it as a volume.

Wildcard certificates with DNS plugins#

Standard builds have no DNS provider modules. Build one in with xcaddy or use caddy add-package on a packaged install, then use the dns subdirective of tls (per site) or acme_dns (global) with the credentials in environment variables, never in the Caddyfile.

xcaddy build --with github.com/caddy-dns/cloudflare      # produces ./caddy
caddy add-package github.com/caddy-dns/cloudflare        # replaces the binary in place; the systemd service needs a restart
caddy list-modules | grep dns.providers
*.example.com, example.com {
	tls {
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
		resolvers 1.1.1.1 8.8.8.8                 # query these for propagation checks instead of the local resolver
		propagation_timeout 2m
	}

	@app host app.example.com
	handle @app {
		reverse_proxy 127.0.0.1:8080
	}

	@grafana host grafana.example.com
	handle @grafana {
		reverse_proxy 127.0.0.1:3000
	}

	handle {                                      # unknown subdomains: close the connection
		abort
	}
}

One site block with a wildcard address serves every subdomain under one certificate, which keeps hostnames out of the public certificate transparency logs. Each handle with a host matcher is one virtual host. For the systemd service, put the token in /etc/caddy/caddy.env with mode 0600 and reference it from a drop-in with EnvironmentFile=, as covered on the systemd page. Split-horizon DNS is the other reason for DNS-01: an internal name that resolves only on the LAN can still get a public certificate.

Reverse proxy#

reverse_proxy keeps the client’s Host header, sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, and upgrades WebSocket connections without any extra directives. Retrying, buffering and TLS to the upstream are subdirectives.

app.example.com {
	reverse_proxy 127.0.0.1:8080 {
		header_up X-Real-IP {remote_host}         # some apps read this instead of X-Forwarded-For
		header_up Host {upstream_hostport}        # only if the backend needs its own name in Host
		header_down -Server
		transport http {
			dial_timeout 5s
			response_header_timeout 60s           # 504 upstream after this
			read_buffer 8KiB
		}
		flush_interval -1                         # stream responses immediately (SSE); auto-detected for text/event-stream
	}
}

api.example.com {
	handle_path /v1/* {                           # strips /v1 before proxying; "handle" would keep it
		reverse_proxy 127.0.0.1:8081
	}
	handle {
		respond 404
	}
}

secure.example.com {
	reverse_proxy https://backend.internal:8443 {  # https:// scheme turns on TLS to the upstream
		transport http {
			tls_trust_pool file /etc/caddy/internal-ca.pem   # 2.8+; or tls_insecure_skip_verify for testing only
			tls_server_name backend.internal
		}
	}
}

X-Forwarded-* headers from the client are stripped and replaced unless the connection comes from an address listed under trusted_proxies, so Caddy behind another proxy needs that global option or every log shows the front proxy’s address. Placeholders usable in header_up include {remote_host}, {host}, {uri}, {http.request.tls.client.subject} and any request header as {header.Name}.

Load balancing and health checks#

app.example.com {
	reverse_proxy 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080 {
		lb_policy least_conn                      # default random; also round_robin, ip_hash, first, cookie, header, uri_hash
		lb_try_duration 5s                        # keep trying other upstreams for this long on failure
		lb_try_interval 250ms
		lb_retries 2

		health_uri /healthz                       # active check; without it only passive checks apply
		health_interval 10s
		health_timeout 3s
		health_status 200
		health_passes 2                           # 2.8+: consecutive passes before marking healthy
		health_fails 3

		fail_duration 30s                         # passive: remember a failure this long
		max_fails 2                               # failures within fail_duration before the upstream is skipped
		unhealthy_status 5xx
		unhealthy_latency 2s
	}
}

With first policy and an upstream marked backup you get active/standby. Retries are only safe for idempotent requests; lb_retry_match restricts them, and by default only requests that never reached the upstream are retried. curl -s localhost:2019/reverse_proxy/upstreams | jq shows each upstream’s healthy flag and in-flight request count and is the first thing to check when a proxy returns 502 for one backend.

Static files, redirects and matchers#

www.example.com {
	root * /srv/www/example
	encode zstd gzip
	try_files {path} {path}/ /index.html           # single-page app fallback
	file_server {
		hide .git .env*
		precompressed br gzip                     # serve file.br if it exists
	}
	@immutable path /assets/*
	header @immutable Cache-Control "public, max-age=31536000, immutable"
}

files.example.com {
	root * /srv/files
	file_server browse                            # directory listing
}

example.com {
	redir https://www.example.com{uri} permanent  # 301; "temporary" is 302; default is 302
}

old.example.com {
	redir /docs/* /help{uri} 301
	handle_path /legacy/* {
		rewrite * /new{uri}                        # internal rewrite, client sees nothing
		reverse_proxy 127.0.0.1:8080
	}
}

A matcher is a token starting with @ that a directive accepts as its first argument, or * for everything, or a path prefix such as /api/*. Named matchers combine conditions with AND; repeat a condition for OR.

@internal remote_ip 192.0.2.0/24 10.0.0.0/8
@api {
	path /api/*
	method GET POST
	not header Authorization ""
}
@bots header_regexp User-Agent (?i)(curl|python-requests)
@websocket {
	header Connection *Upgrade*
	header Upgrade websocket
}
@expr expression {path}.startsWith("/admin") && {method} != "GET"

respond @bots 403
handle @internal { reverse_proxy 127.0.0.1:9000 }

Path matching is exact unless the pattern ends with *, and /api* also matches /apiary. handle blocks are mutually exclusive: the first whose matcher matches wins, and an unmatched handle acts as the fallback. Directives outside any handle apply to every request in the site.

Basic auth and access control#

metrics.example.com {
	basic_auth {
		alice $2a$14$Zkx19WLrIFnP3cU9ov8mxeP0v9v8rKKvz4kJcT8i0J5WQt3z8I3S6   # from caddy hash-password
	}
	reverse_proxy 127.0.0.1:9090
}

admin.example.com {
	@office remote_ip 192.0.2.0/24
	handle @office {
		reverse_proxy 127.0.0.1:8080
	}
	respond "forbidden" 403
}

sso.example.com {
	forward_auth 127.0.0.1:9091 {                 # ask an auth service first; 2xx lets the request through
		uri /api/verify
		copy_headers Remote-User Remote-Groups
	}
	reverse_proxy 127.0.0.1:8080
}

caddy hash-password produces a bcrypt hash and reads the password from a prompt, so it never appears in shell history. Basic auth over HTTPS only; Caddy will happily serve it on an http:// site. Sites that serve the admin API or metrics to the internet need one of these in front.

Snippets, imports and reuse#

(proxy) {
	reverse_proxy {args[0]} {
		header_up X-Real-IP {remote_host}
		transport http {
			dial_timeout 5s
		}
	}
}

(auth) {
	basic_auth {
		{args[0]} {args[1]}
	}
}

app.example.com {
	import proxy 127.0.0.1:8080
}

grafana.example.com {
	import auth alice {$GRAFANA_HASH}
	import proxy 127.0.0.1:3000
}

Snippets are defined at the top level, take positional arguments as {args[0]} (or {args[:]} for all), and are expanded by import before adaptation, so caddy adapt shows the result. import also takes file globs, and a file imported from another directory resolves its own relative paths from the importing file. The caddy fmt output is the canonical style: tabs, one directive per line.

Running in Docker or Podman#

The official image is caddy:2; caddy:2-builder has xcaddy for plugins. Persist /data (certificates, CA, ACME account) and /config (autosave), and publish 443/udp for HTTP/3.

FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare

FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
services:
  caddy:
    build: .
    restart: unless-stopped
    ports: ["80:80", "443:443", "443:443/udp"]
    environment:
      CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}   # from .env, not committed
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro,Z        # :Z for SELinux hosts; drop it on Docker without SELinux
      - caddy_data:/data
      - caddy_config:/config
volumes:
  caddy_data:
  caddy_config:
docker compose exec -w /etc/caddy caddy caddy reload     # after editing the Caddyfile; no restart needed
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy-root.crt   # internal CA root

Inside a Compose network the upstream is the service name (reverse_proxy my-app:8080), and Caddy resolves it through the embedded DNS on every dial, so restarted containers with new IPs are picked up. Rootless Podman cannot bind 80 and 443 until net.ipv4.ip_unprivileged_port_start is lowered, and needs the :Z label on bind mounts. See Docker Compose for the stack conventions.

Admin API#

The admin endpoint is plain HTTP on localhost:2019 with no authentication; anything that can reach it owns the server. Keep it on loopback or a Unix socket (admin unix//run/caddy/admin.sock).

curl -s localhost:2019/config/ | jq .                                    # whole running config
curl -s localhost:2019/config/apps/http/servers/srv0/routes | jq          # a subtree by path
curl -s localhost:2019/config/apps/tls/automation | jq
curl -X POST localhost:2019/load -H 'Content-Type: text/caddyfile' --data-binary @Caddyfile   # what caddy reload does
curl -X POST localhost:2019/adapt -H 'Content-Type: text/caddyfile' --data-binary @Caddyfile | jq   # adapt without loading
curl -X PATCH localhost:2019/config/apps/http/servers/srv0/listen -H 'Content-Type: application/json' -d '[":8443"]'
curl -s localhost:2019/reverse_proxy/upstreams | jq                      # health of every upstream
curl -s localhost:2019/pki/ca/local | jq -r .root_certificate             # internal CA root PEM
curl -X POST localhost:2019/stop                                          # graceful shutdown
curl -s localhost:2019/metrics                                             # Prometheus format; 2.10+ needs the "metrics" global option

Config loaded through the API is not written back to the Caddyfile; the next caddy reload from the file replaces it. @id tags in JSON config give stable handles (/id/my-route) for automation that adds and removes routes, which is how ingress controllers and caddy-docker-proxy drive it. Metrics are scraped by Prometheus with caddy_http_requests_total and caddy_http_request_duration_seconds as the main series.

Logs#

Caddy logs structured JSON (or console format on a TTY) to stderr, which journalctl -u caddy captures. Access logs are off until a site has a log directive.

app.example.com {
	log {
		output file /var/log/caddy/app.access.log {
			roll_size 100MiB
			roll_keep 10
			roll_keep_for 720h
		}
		format json
		level INFO
	}
	reverse_proxy 127.0.0.1:8080
}

Each access line carries request.host, request.uri, request.remote_ip, status, duration (seconds), size, resp_headers and, when proxying, "logger":"http.log.access" plus a separate http.handlers.reverse_proxy entry at DEBUG with the upstream and its latency. The global debug option turns that on and is the right first move for any proxying or ACME problem. Errors from ACME show under the tls.obtain and tls.issuance.acme loggers with the challenge type and the CA’s error text verbatim.

journalctl -u caddy --since -30m | grep -E '"level":"(error|warn)"'
journalctl -u caddy -o cat | jq -r 'select(.logger=="tls.obtain") | "\(.ts|todate) \(.identifier // "") \(.msg) \(.error // "")"'
jq -r '"\(.ts|todate) \(.status) \(.duration*1000|floor)ms \(.request.host)\(.request.uri)"' /var/log/caddy/app.access.log | tail -20
jq -r 'select(.status >= 500) | .request.uri' /var/log/caddy/app.access.log | sort | uniq -c | sort -rn | head

Oneliners#

# Validate, then reload only if valid
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy

# Diff what the Caddyfile would load against what is running
diff <(caddy adapt --config /etc/caddy/Caddyfile | jq -S .) <(curl -s localhost:2019/config/ | jq -S .)

# Every site address in the running config
curl -s localhost:2019/config/apps/http/servers | jq -r '.[].routes[].match[]?.host[]?' | sort -u

# Certificates Caddy is managing and their expiry
find /var/lib/caddy/.local/share/caddy/certificates -name '*.crt' -exec sh -c 'printf "%s " "$1"; openssl x509 -enddate -noout -in "$1"' _ {} \;

# Unhealthy upstreams only
curl -s localhost:2019/reverse_proxy/upstreams | jq -r '.[] | select(.healthy==false) | .address'

# Watch upstream request counts
watch -n1 'curl -s localhost:2019/reverse_proxy/upstreams | jq -c ".[] | {address, healthy, num_requests}"'

# Which ACME challenge failed and why, from the journal
journalctl -u caddy -o cat --since -1h | jq -r 'select(.logger|test("acme")) | "\(.ts|todate) \(.msg) \(.error // "") \(.challenge_type // "")"'

# Export the internal CA root for another machine
curl -s localhost:2019/pki/ca/local | jq -r .root_certificate > caddy-root.crt

# Confirm HTTP/3 is served (UDP 443 must be open)
curl -sI --http3-only https://app.example.com/ | head -1

# Confirm the redirect from HTTP
curl -sI http://app.example.com/ | grep -i '^location'

# Test a site block before DNS points at the server
curl -sk --resolve app.example.com:443:203.0.113.10 https://app.example.com/ -o /dev/null -w '%{http_code} %{ssl_verify_result}\n'

# Serve the current directory on :8000 with listing, for a quick file transfer
caddy file-server --listen :8000 --browse

# Proxy a local dev server with a trusted local certificate
caddy reverse-proxy --from localhost:8443 --to localhost:3000

# Bcrypt hash without a prompt (the plaintext lands in shell history; prefer the prompt)
caddy hash-password --plaintext "$PASSWORD"

# Environment as Caddy sees it (what {$VAR} placeholders will expand to)
systemctl show caddy -p Environment -p EnvironmentFiles

# Top clients in the last 10k access log lines
tail -n 10000 /var/log/caddy/app.access.log | jq -r .request.remote_ip | sort | uniq -c | sort -rn | head

# p95 latency in ms from the access log
jq -r .duration /var/log/caddy/app.access.log | sort -n | awk '{a[NR]=$1} END {print a[int(NR*0.95)]*1000 "ms"}'

# Restart the service and follow startup
systemctl restart caddy && journalctl -u caddy -f -n 50

Scripts#

Report every managed certificate with days until expiry and flag any that Caddy should have renewed already (it renews at two thirds of lifetime, so under 30 days for a 90-day certificate means renewal is failing).

#!/usr/bin/env bash
set -euo pipefail
dir=${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}/certificates
rc=0
while IFS= read -r crt; do
  name=$(openssl x509 -noout -subject -in "$crt" | sed 's/.*CN *= *//')
  end=$(date -d "$(openssl x509 -noout -enddate -in "$crt" | cut -d= -f2)" +%s)
  days=$(( (end - $(date +%s)) / 86400 ))
  if (( days < 30 )); then
    printf 'RENEWAL OVERDUE %-40s %3d days\n' "$name" "$days"; rc=1
  else
    printf 'ok              %-40s %3d days\n' "$name" "$days"
  fi
done < <(find "$dir" -name '*.crt' | sort)
exit "$rc"

Add a site to a running Caddy through the admin API without touching the Caddyfile, for short-lived environments. The route is tagged with an @id so the matching removal is one DELETE.

#!/usr/bin/env bash
set -euo pipefail
host=${1:?hostname}; upstream=${2:?upstream host:port}
admin=${CADDY_ADMIN:-localhost:2019}
route=$(jq -n --arg h "$host" --arg u "$upstream" '{
  "@id": ("route-" + $h),
  match: [{host: [$h]}],
  handle: [{handler: "reverse_proxy", upstreams: [{dial: $u}]}],
  terminal: true }')
curl -fsS -X POST "http://$admin/config/apps/http/servers/srv0/routes" \
  -H 'Content-Type: application/json' -d "$route"
printf 'added %s -> %s; remove with: curl -X DELETE http://%s/id/route-%s\n' "$host" "$upstream" "$admin" "$host"

Check every upstream from the admin API and exit non-zero if any is unhealthy, suitable for a monitoring probe or a deploy gate.

#!/usr/bin/env python3
import json, sys, urllib.request
admin = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:2019"
with urllib.request.urlopen(f"{admin}/reverse_proxy/upstreams", timeout=5) as r:
    upstreams = json.load(r)
bad = [u for u in upstreams if not u["healthy"]]
for u in upstreams:
    state = "ok" if u["healthy"] else "UNHEALTHY"
    print(f"{state:10} {u['address']:30} inflight={u['num_requests']} fails={u['fails']}")
sys.exit(1 if bad else 0)

Troubleshooting#

SymptomCauseFix
Startup log: could not get certificate from issuer, then retries with backoffACME challenge failed; the error text names the reasonjournalctl -u caddy | grep acme; read the CA’s message, fix, and Caddy retries on its own
no solvers available for remaining challengesPort 80 and 443 both unreachable from the internet, or the name resolves elsewheredig +short app.example.com from outside; open 80/443 on the firewall and the router; check nothing else holds port 80 (ss -ltnp 'sport = :80')
Timeout during connect (likely firewall problem) from Let’s EncryptHTTP-01 blocked, common on home connections where the ISP filters 80Use DNS-01 with a DNS plugin
too many certificates already issuedLet’s Encrypt rate limit after repeated fresh startsPersist /data; use acme_ca staging while testing
DNS problem: NXDOMAINName has no public record, or a private/split-horizon nameAdd the record, or use DNS-01 and resolvers pointing at public DNS
Wildcard site: acme: error presenting token or propagation timeoutDNS plugin token lacks zone edit rights, or the wrong zoneTest the token with the provider’s API; raise propagation_timeout; set resolvers
Browser shows the internal CA certificate for a public nameCaddy fell back to internal issuance after ACME failures, or tls internal is inherited via a snippetCheck the issuer with openssl s_client; fix the ACME error; remove tls internal
x509: certificate signed by unknown authority when proxying to an https:// upstreamThe upstream’s certificate is self-signed or private-CAtls_trust_pool file /path/ca.pem in the transport, or tls_insecure_skip_verify for a test only
Upstream gets http in X-Forwarded-Proto or a wrong client IPCaddy sits behind another proxy that is not in trusted_proxiesGlobal servers { trusted_proxies static <cidr> }
502 for every request, upstream is upUpstream listens on 127.0.0.1 inside its container, or Caddy is in a different Compose networkdocker compose exec caddy wget -qO- my-app:8080/ ; put both services on the same network
502 for one backend in a poolActive check failing or max_fails reachedcurl -s localhost:2019/reverse_proxy/upstreams | jq; hit health_uri yourself
Reload says connection refused on 2019admin off, or Caddy runs in a container and the command runs on the hostRun caddy reload inside the container; do not disable admin if you need reloads
Reloading from the Caddyfile reverts API changesThe file is the source on reload; API edits are not persisted to itKeep one source of truth, or run with --resume and JSON
handle_path upstream returns 404The stripped path is not what the app expects, or the wrong handle matchedcaddy adapt and read the route order; use handle instead of handle_path
bind: permission denied on 80/443 as non-rootMissing capabilityThe package sets CAP_NET_BIND_SERVICE; for containers see Podman for unprivileged ports
HTTP/3 never negotiatedUDP 443 not published or filteredPublish 443/udp; check with curl --http3-only

Further reading#