How Does Caddy's Certificate Automation Work? A Source Code Deep Dive
Caddy's certificate automation orchestrates the entire TLS certificate lifecycle through an AutomationConfig that provisions AutomationPolicies, which delegate issuance to modular Issuer implementations (ACME or Internal), all integrated with CertMagic for automatic renewal and OCSP stapling.
Caddy's certificate automation eliminates manual TLS certificate management by automatically obtaining, renewing, and loading certificates for your domains. According to the caddyserver/caddy source code, this system is implemented through a sophisticated policy-based architecture in the modules/caddytls package that separates global orchestration settings from per-domain issuance rules.
The Three Pillars of Certificate Automation
Caddy's certificate automation rests on three core abstractions defined in modules/caddytls/automation.go.
AutomationConfig (lines 36-66) contains global settings that enable and tune automation, including the RenewCheckInterval (default 10 minutes), OCSPCheckInterval (default 1 hour), and OnDemand configuration.
AutomationPolicy (lines 84-154) defines per-subject rules that determine which issuer to use, how to handle ACME challenges, and when to renew certificates. Each policy can specify its own storage backend, issuer list, and on-demand permission callbacks.
Issuer modules are concrete implementations that obtain certificates from specific sources. The two built-in types are ACMEIssuer (modules/caddytls/acmeissuer.go, lines 44-88) for public certificates via Let's Encrypt or ZeroSSL, and InternalIssuer (modules/caddytls/internalissuer.go, lines 37-55) for private PKI using local CAs.
How the TLS App Provisions the Automation Stack
When Caddy starts, the TLS application's Provision method in modules/caddytls/tls.go (lines 60-78) creates a certificate cache (certmagic.Cache) and initializes the automation stack:
if t.Automation != nil {
cacheOpts.OCSPCheckInterval = time.Duration(t.Automation.OCSPCheckInterval)
cacheOpts.RenewCheckInterval = time.Duration(t.Automation.RenewCheckInterval)
}
If no automation block is present in the configuration, Caddy automatically creates a default public policy (defaultPublicAutomationPolicy) and, for subjects that do not qualify for public certificates, a default internal policy (defaultInternalAutomationPolicy). Both are provisioned via AutomationPolicy.Provision (lines 92-106):
t.Automation.defaultPublicAutomationPolicy = new(AutomationPolicy)
err = t.Automation.defaultPublicAutomationPolicy.Provision(t)
Inside AutomationPolicy.Provision
The AutomationPolicy.Provision method performs four critical initialization steps. First, it performs placeholder replacement on subject names using Caddy's replacer to expand ${env} variables. Second, it loads a per-policy storage module if configured via the storage field. Third, it initializes issuers—if the user supplied an issuers list, each is loaded via LoadModule; if omitted, DefaultIssuersProvisioned supplies a standard ACMEIssuer.
Fourth, for On-Demand TLS, when on_demand is true or managers are defined, it builds a certmagic.OnDemandConfig with a permission decision callback (lines 66-88):
if ap.OnDemand || len(ap.Managers) > 0 {
ond = &certmagic.OnDemandConfig{
DecisionFunc: func(ctx context.Context, name string) error { … },
Managers: ap.Managers,
}
}
Finally, the method instantiates a certmagic.Config with all resolved options (OCSP settings, key type, preferred chains) and stores it in ap.magic. Issuers receive this config via the ConfigSetter interface (lines 56-63):
for _, issuer := range ap.magic.Issuers {
if annoying, ok := issuer.(ConfigSetter); ok {
annoying.SetConfig(ap.magic)
}
}
Public Certificate Issuance with ACMEIssuer
The ACMEIssuer struct in modules/caddytls/acmeissuer.go implements certmagic.Issuer, certmagic.PreChecker, and certmagic.Renewer. Its Provision method constructs an acmez-compatible template via makeIssuerTemplate (lines 28-46):
iss.template, err = iss.makeIssuerTemplate(ctx)
This template includes the CA directory URL (CA or TestCA), contact email for registration, challenge configuration (HTTP-01, TLS-ALPN-01, or DNS-01), and optional network proxy settings. When the DNS challenge is used, ACMEIssuer.Provision creates a certmagic.DNS01Solver from the configured DNS provider module (lines 73-84).
During a TLS handshake, the issuer's PreCheck method runs first to verify domain permission (checking on-demand authorization and wildcard validity). If allowed, the Issue method forwards the Certificate Signing Request (CSR) to the ACME server.
Private Certificates with InternalIssuer
For domains that do not qualify for public certificates (such as *.local or 10.* addresses), Caddy uses InternalIssuer defined in modules/caddytls/internalissuer.go (lines 37-55):
type InternalIssuer struct {
CA string `json:"ca,omitempty"`
Lifetime caddy.Duration `json:"lifetime,omitempty"`
SignWithRoot bool `json:"sign_with_root,omitempty"`
ca *caddypki.CA
}
During provisioning, it retrieves the Certificate Authority from the PKI app (ctx.App("pki")) and uses Smallstep's Authority to sign CSRs locally. This provides valid TLS for internal networks without requiring public internet access or external validation.
Matching Requests to Policies
When Caddy needs a certificate for a hostname, it calls TLS.getAutomationPolicyForName in modules/caddytls/tls.go (lines 70-88). This method iterates through AutomationConfig.Policies and returns the first policy whose subject list matches the requested name using certmagic.MatchWildcard for wildcard support:
for _, ap := range t.Automation.Policies {
if len(ap.subjects) == 0 { return ap }
for _, h := range ap.subjects {
if certmagic.MatchWildcard(name, h) { return ap }
}
}
If no custom policy matches, Caddy falls back to defaultPublicAutomationPolicy for publicly routable domains or defaultInternalAutomationPolicy for private names.
Batching and Managing Subjects
The TLS.Manage method groups incoming subjects by their matching automation policy to optimize resource usage. It creates a policyToNames map, then calls ManageAsync once per group rather than once per domain (lines 61-85):
policyToNames := make(map[*AutomationPolicy][]string)
for subj := range subjects {
ap := t.getAutomationPolicyForName(subj)
policyToNames[ap] = append(policyToNames[ap], subj)
}
for ap, names := range policyToNames {
err := ap.magic.ManageAsync(t.ctx.Context, names)
…
}
This approach minimizes the number of certmagic.Config instances and enables efficient batch renewal operations.
On-Demand TLS: Deferred Issuance
When a policy has on_demand: true, Caddy defers certificate issuance until the actual TLS handshake occurs. The permission decision is performed via the permission module configured under automation.on_demand.permission. If no module is configured and the policy is unbounded (wildcard), Caddy aborts with an error to prevent abuse.
Automatic Renewal and OCSP Stapling
The global AutomationConfig.RenewCheckInterval (default 10 minutes) drives CertMagic's background renewal scanner, which checks for expiring certificates and initiates re-issuance automatically. The OCSPCheckInterval (default 1 hour) triggers updates to OCSP staples. Individual policies may override these intervals through their own configuration fields, allowing fine-grained control over check frequency for different certificate sets.
Practical Configuration Examples
Basic Automatic HTTPS with Global Settings
{
automation {
renew_interval 5m
ocsp_interval 30m
}
}
example.com, www.example.com {
reverse_proxy localhost:8080
}
This configuration relies on the defaultPublicAutomationPolicy to automatically obtain certificates from Let's Encrypt for the specified domains.
Explicit Subject Management with the Automate Loader
{
tls {
certificates {
automate example.com www.example.com api.example.com
}
}
}
The automate loader explicitly lists managed domains, storing them in t.automateNames for processing by TLS.Manage.
On-Demand TLS with Permission Endpoint
{
automation {
on_demand {
ask https://my-auth.example.com/allow
}
}
}
Caddy calls the ask endpoint during each handshake. A 200 OK response permits certificate issuance; any other status aborts the connection.
Wildcard Certificates with DNS Challenge
tls {
automation {
policies {
{
subjects *.example.com
issuers acme {
dns cloudflare {
api_token ${CLOUDFLARE_TOKEN}
}
}
}
}
}
}
The dns cloudflare block triggers ACMEIssuer.Provision to configure a certmagic.DNS01Solver for ACME DNS-01 challenges.
Internal PKI for Private Domains
{
pki {
ca local {
key_type rsa2048
lifetime 8760h
}
}
}
tls {
automation {
policies {
{
subjects internal.example.com *.local
issuers internal {}
}
}
}
}
This uses InternalIssuer to sign certificates with the local CA defined in the PKI app, providing valid TLS for non-public domains without external ACME servers.
Summary
- Caddy's certificate automation is governed by
AutomationConfigandAutomationPolicystructs defined inmodules/caddytls/automation.go, separating global settings from per-domain rules. - The TLS app (
modules/caddytls/tls.go) initializes acertmagic.CacheduringProvision, then matches domains to policies viagetAutomationPolicyForName. - ACMEIssuer (
modules/caddytls/acmeissuer.go) handles public certificates using the acmez library with support for HTTP, TLS-ALPN, and DNS challenges. - InternalIssuer (
modules/caddytls/internalissuer.go) provides private PKI integration using thecaddypkiapp for internal domains. - On-Demand TLS defers issuance until handshake time, requiring explicit permission modules to prevent abuse.
- Batch management via
TLS.Managegroups domains by policy and callsManageAsyncfor efficient renewal and OCSP updates.
Frequently Asked Questions
What happens if I don't specify any automation configuration?
Caddy automatically creates defaultPublicAutomationPolicy and defaultInternalAutomationPolicy during the TLS app's Provision phase. Any publicly qualified domain uses the default ACME issuer (typically Let's Encrypt), while private domains fall back to the internal issuer, requiring no manual configuration for standard use cases.
How does Caddy decide which automation policy to use for a domain?
Caddy calls getAutomationPolicyForName in modules/caddytls/tls.go, which iterates through configured policies in order and returns the first match based on exact or wildcard (certmagic.MatchWildcard) subject matching. If no custom policy matches, it returns the default public policy for internet-facing domains or the default internal policy for private names.
What is the difference between ACMEIssuer and InternalIssuer?
ACMEIssuer (modules/caddytls/acmeissuer.go) communicates with external ACME servers like Let's Encrypt to obtain publicly trusted certificates, supporting HTTP-01, TLS-ALPN-01, and DNS-01 challenges. InternalIssuer (modules/caddytls/internalissuer.go) uses the local PKI app (caddypki) to sign certificates with a private CA, suitable for internal networks, development environments, or domains that cannot pass public validation.
How does On-Demand TLS prevent abuse?
When on_demand is enabled without explicit subject lists, Caddy requires a permission module (configured via ask or custom modules) that acts as a decision callback during the TLS handshake. If the permission endpoint does not return 200 OK, Caddy aborts the handshake before attempting issuance, preventing unauthorized certificate generation for arbitrary domains.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →