Configuration: ACME automatic TLS

This page documents the ACME TLS provider (the tls-acme module). It automatically gets TLS certificates from ACME-compatible Certificate Authorities (CAs) such as Let’s Encrypt. The provider supports both eager (startup-time) and on-demand (lazy, first-connection) certificate issuance. It offers three challenge types:

  • HTTP-01: serves a token at /.well-known/acme-challenge/ (default)
  • TLS-ALPN-01: responds with a self-signed cert during the TLS handshake
  • DNS-01: creates a TXT record at _acme-challenge.<domain>

Ferron caches certificates (both in-memory and file-based) and renews them automatically before expiration.

Ferron enables automatic TLS via ACME by default for public hosts:

example.com {
    # Automatic TLS is enabled by default, no explicit TLS directive needed
}

Directives⁠#

Challenge types⁠#

HTTP-01 (default)⁠#

The simplest challenge type. The server listens on port 80 to serve /.well-known/acme-challenge/<token>.

example.com {
    tls {
        provider acme
        challenge http-01
        contact "admin@example.com"
    }
}

Requirements: The server must be reachable on port 80 for the ACME CA to validate the challenge.

TLS-ALPN-01⁠#

The server responds with a self-signed certificate when the CA connects with the acme-tls/1 ALPN protocol. It needs no additional port.

example.com {
    tls {
        provider acme
        challenge tls-alpn-01
        contact "admin@example.com"
    }
}

Requirements: The server must be reachable on port 443. Does not support wildcard domains.

DNS-01 (required for wildcard domains)⁠#

Creates a _acme-challenge TXT record via a DNS provider. The only challenge type that supports wildcard certificates.

*.example.com {
    tls {
        provider acme
        challenge dns-01
        contact "admin@example.com"
        dns {
            provider cloudflare
            api_key "EXAMPLE_API_KEY"
        }
    }
}

Requirements: Configure a DNS provider module. The module supports wildcard domains. The dns block must specify the provider name and any provider-specific credentials. See DNS providers for the full list of supported providers and their directives.

Configuration parameters⁠#

ParameterTypeDefaultDescription
provideracmenoneMust be "acme"
challengehttp-01, tls-alpn-01, dns-01http-01ACME challenge type
contact<string>noneEmail for ACME account
directory<string>LE ProductionACME directory URL
profile<string>noneACME profile name (optional)
eab"<key-id>" "<hmac>"noneExternal Account Binding
cache<string>/var/cache/ferron-acme if on Unix and writable, otherwise platform data dirPath for file-based certificate caching
save<cert> [key]noneSave cert (and optionally key) to disk
post_obtain_command<string>noneCommand to run after certificate issuance
no_verification<bool>falseSkip ACME directory TLS verification
on_demand<bool>falseEnable on-demand certificate issuance
on_demand_ask<string>noneApproval endpoint URL
on_demand_ask_auth<string>noneAuthorization header for approval endpoint (optional)
on_demand_ask_no_verification<bool>falseSkip TLS verification for approval endpoint
fallback{ ... }noneFallback provider block (repeatable)

Configuration example:

example.com {
    tls {
        provider acme
        challenge http-01
        contact "admin@example.com"

        directory "https://acme-v02.api.letsencrypt.org/directory"
        cache "/var/cache/ferron-acme"

        save "/etc/ssl/certs/example.com.pem" "/etc/ssl/private/example.com.pem"
        # `post_obtain_command` arg is a script/binary name + args, separated by spaces.
        post_obtain_command "/var/lib/post_obtain_command.sh"

        ocsp
    }
}

Eager mode gets certificates at server startup, before any client traffic arrives. This is ideal for static configurations where you know all domain names in advance.

On-demand mode⁠#

On-demand mode defers certificate issuance until the first TLS handshake for a hostname. This is useful for wildcard domains, multi-tenant hosting, or when you do not know the domains at startup.

*.example.com {
    tls {
        provider acme
        challenge dns-01
        contact "admin@example.com"
        on_demand
    }
}

On-demand approval endpoint⁠#

To prevent abuse, you can configure an approval endpoint. Before issuing a certificate, Ferron sends an HTTP GET request to the endpoint with ?domain=<sni> as a query parameter. If the response is 200, Ferron issues the certificate.

*.example.com {
    tls {
        provider acme
        challenge http-01
        contact "admin@example.com"
        on_demand
        on_demand_ask "https://internal-api.example.com/check-cert"
    }
}

Certificate caching⁠#

In-memory cache (default)⁠#

Without a cache path, Ferron stores certificates and account data in memory.

File-based cache⁠#

Setting a cache path persists certificates and accounts to disk, surviving restarts:

example.com {
    tls {
        provider acme
        challenge http-01
        contact "admin@example.com"
        cache "/var/cache/ferron-acme"
    }
}

The cache directory structure:

/var/cache/ferron-acme/
├── account_<hash>          # ACME account credentials
└── certificate_<hash>      # Certificate chain + private key (JSON)

Cache key derivation⁠#

  • Account cache key: hash of contact emails + directory URL
  • Certificate cache key: hash of sorted domains + profile name

Certificate renewal⁠#

Ferron automatically renews certificates before expiration. The renewal check runs every 10 seconds in the background. Ferron uses the ACME renewalInfo endpoint (RFC 9773) when available. Otherwise it falls back to a heuristic of 50% of certificate lifetime, capped at 24 hours before expiry.

External Account Binding (EAB)⁠#

Some CAs (especially enterprise/internal ACME servers) require External Account Binding. Set the key ID and HMAC secret:

tls {
    provider acme
    challenge http-01
    contact "admin@example.com"
    eab "my-key-id" "SMq9KpHkR7z..."
    directory "https://acme.internal.example.com/directory"
}

The HMAC secret must be base64url-encoded (without padding).

Fallback providers⁠#

When high availability is critical, you can configure fallback ACME providers. If the primary provider fails (for example, the CA is down or unreachable), Ferron tries the next configured fallback provider when certificate provisioning fails.

example.com {
    tls {
        provider acme
        challenge http-01
        contact "admin@example.com"
        directory "https://acme-v02.api.letsencrypt.org/directory"

        fallback {
            directory "https://acme-staging-v02.api.letsencrypt.org/directory"
            contact "admin@example.com"
        }

        fallback {
            directory "https://other-ca.example.com/directory"
            eab "my-key-id" "SMq9KpHkR7z..."
            profile "my-profile"
        }
    }
}

Each fallback block accepts the same provider-level directives as the primary configuration: directory, contact, eab, and profile. The fallback block inherits the settings of the primary provider. You only need to specify the fields that differ.

Example: primary with staging fallback⁠#

A common pattern is to configure production as the primary and staging as a fallback. Use this for testing or when production is unavailable:

example.com {
    tls {
        provider acme
        challenge http-01
        contact "admin@example.com"
        directory "https://acme-v02.api.letsencrypt.org/directory"

        fallback {
            directory "https://acme-staging-v02.api.letsencrypt.org/directory"
            contact "admin@example.com"
        }
    }
}

Saving certificates to disk⁠#

To persist certificates you get for use by other tools or backup:

tls {
    provider acme
    challenge http-01
    contact "admin@example.com"
    save "/etc/ssl/certs/example.com.pem" "/etc/ssl/private/example.com.pem"
}

If you give only one path, the key path defaults to the certificate path with a .key extension. After Ferron gets a certificate, it writes the private key with 0600 permissions on Unix.

Security considerations⁠#

  • Private keys are never logged or exposed in error messages.
  • When saved to disk, Ferron writes keys with 0600 permissions on Unix.
  • When using on-demand mode, always configure an on_demand_ask endpoint in production to prevent certificate issuance for arbitrary hostnames.

Troubleshooting⁠#

“ACME certificate provisioning error: …”⁠#

Certificate issuance failed. The log message includes the affected domains. Check the error message for details (DNS resolution, ACME server errors, and so on). At debug log level, you will also see per-step messages for account loading, order creation, challenge solving, and certificate installation.

DNS-01 issues⁠#

  • Make sure you configure the DNS provider correctly with valid credentials.
  • Check that the provider has permission to create TXT records for the domain.
  • DNS propagation may take longer than 60 seconds for some providers. In these cases, the ACME CA will retry validation.

Verifying certificates⁠#

# Check the certificate served by Ferron
echo | openssl s_client -connect example.com -servername example.com 2>/dev/null | openssl x509 -noout -subject -dates -issuer

# Verify OCSP stapling
openssl s_client -connect example.com -status -servername example.com </dev/null 2>/dev/null | grep -A 5 "OCSP response"

Observability⁠#

The ACME background task emits log events and metrics through the configured observability pipeline:

Note

The ACME log events are global-only. Ferron does not emit them via per-host observability sinks.

Logs⁠#

LevelMessageWhen
INFOACME background task started with N configuration(s) for domains: ...Service initialization
INFOOn-demand certificate requested for SNI <host>:<port>On-demand certificate request received
INFOOn-demand certificate pre-loaded for SNI <host>:<port>On-demand certificate loaded from cache
INFOACME certificate issued for domains: ...Successful certificate issuance
INFOACME account created for directory ..., contact: ...New ACME account registration
INFOPost-obtain command started for ...: <cmd>Post-obtain hook execution
WARNACME certificate provisioning error for ...: <error>Certificate issuance failure
WARNACME account not found on server for ..., recreatingAccount expired/removed on CA side
WARNPost-obtain command failed for ...: <error>Post-obtain hook error
WARNPost-obtain command has malformed quoting for ...Post-obtain hook malformed command
WARNFailed to save ACME account cache: <error>Account cache write failure
WARNFailed to save ACME certificate cache: <error>Certificate cache write failure
DEBUGACME provisioning cycle started — checking N configurationsEach background loop iteration
DEBUGACME account loaded from cache for ...Account reused from cache
DEBUGACME order created for domains: ...New order placed with CA
DEBUGACME <type> challenge initiated for ...Challenge setup started
DEBUGACME <type> challenge solved for ...Challenge ready for validation
DEBUGDNS-01 record created for _acme-challenge.<domain>, TTL <ttl>DNS record published
DEBUGDNS-01 record cleanup completed for _acme-challenge.<domain>DNS record removed
DEBUGCertificate installed for ..., chain length: NCertificate loaded into TLS config

Structured logs⁠#

In OTLP log_style modern, the summary field acts as the log body and Ferron emits attributes as typed OpenTelemetry log record attributes.

SummaryLevelAttributes
ACME background task startedINFOferron.acme.config_count (int), ferron.acme.domains (string)
ACME account createdINFOferron.acme.directory (string): ACME directory URL, ferron.acme.contact (string): account email, ferron.acme.profile (string)
ACME certificate issuedINFOferron.acme.domains (string)
ACME post-obtain command startedINFOferron.acme.domains (string)
On-demand certificate pre-loadedINFOtls.sni (string), tls.port (int)
On-demand certificate requestedINFOtls.sni (string), tls.port (int)
ACME account recreatedWARNferron.acme.domains (string), ferron.acme.directory (string)
ACME certificate provisioning errorWARNferron.acme.domains (string), error.message (string)
ACME post-obtain command malformedWARNferron.acme.domains (string)
ACME post-obtain command failedWARNferron.acme.domains (string), error.message (string)
ACME post-obtain command emptyWARNferron.acme.domains (string)
ACME account cache save failedWARNerror.message (string)
ACME certificate cache save failedWARNerror.message (string)
ACME provisioning cycle startedDEBUGferron.acme.config_count (int)
ACME account loaded from cacheDEBUGferron.acme.domains (string), ferron.acme.provider (string)
ACME order createdDEBUGferron.acme.domains (string)
ACME certificate installedDEBUGferron.acme.domains (string), ferron.acme.chain_length (int)
ACME challenge initiatedDEBUGferron.acme.domains (string), ferron.acme.challenge_type (string)
ACME challenge solvedDEBUGferron.acme.domains (string), ferron.acme.challenge_type (string)
ACME DNS-01 record createdDEBUGferron.acme.dns_challenge_domain (string), ferron.acme.dns_ttl (int)
ACME DNS-01 record cleanupDEBUGferron.acme.dns_challenge_domain (string)
ACME account creation failedWARNferron.acme.domains (string), ferron.acme.provider (string), error.message (string)
ACME account load/create failedERRORferron.acme.domains (string), error.message (string)
ACME order creation failedERRORferron.acme.domains (string), error.message (string), ferron.acme.provider (string)
ACME authorization failedERRORferron.acme.domains (string), ferron.acme.auth_status (string), ferron.acme.provider (string)
ACME challenge type unsupportedERRORferron.acme.domains (string), ferron.acme.challenge_type (string), ferron.acme.provider (string)
ACME identifier type unsupportedERRORferron.acme.domains (string), ferron.acme.identifier_type (string), ferron.acme.provider (string)
ACME challenge ready failedERRORferron.acme.domains (string), error.message (string), ferron.acme.provider (string)
ACME order finalization failedERRORferron.acme.domains (string), error.message (string), ferron.acme.provider (string)
ACME order invalidERRORferron.acme.domains (string)
ACME order not readyERRORferron.acme.domains (string), ferron.acme.order_status (string)
ACME finalize failedERRORferron.acme.domains (string), error.message (string), ferron.acme.provider (string)
ACME certificate obtain failedERRORferron.acme.domains (string), error.message (string), ferron.acme.provider (string)
Certificate issuance deniedERRORtls.sni (string): hostname blocked by ask endpoint
Ask endpoint errorERRORtls.sni (string), error.message (string)

Metrics⁠#

MetricTypeAttributesDescription
ferron.acme.certificates_issued_totalCounterferron.acme.status (success, error), ferron.acme.challenge_typeCertificate issuance outcomes
ferron.acme.on_demand_requests_totalCounterNoneOn-demand certificate requests
ferron.tls.certificate_not_afterGaugeferron.host, ferron.tls.provider (acme), crypto.certificate.serial_numberCertificate notAfter as Unix epoch seconds

Trace spans⁠#

The ACME HTTP-01 challenge stage sets the following attributes on its ferron.stage.acme_http01 span:

AttributeTypeDescription
ferron.acme.domainstringThe domain that Ferron validates.
ferron.acme.challenge_typestringChallenge type (http-01).

See also⁠#

Best practices⁠#

ferron doctor reports the following best-practice checks for directives on this page.

  • no_verification for ACME directory: Only disable TLS verification for the ACME directory in testing.
  • on_demand without on_demand_ask: On-demand certificate issuance without an approval endpoint allows certificate issuance for arbitrary hostnames. Configure on_demand_ask to approve requests.
  • on_demand_ask_no_verification: Only disable TLS verification for the approval endpoint on strictly internal and otherwise authenticated endpoints.
  • Missing contact: Without an ACME account email, the certificate authority cannot send expiry or account notices.
  • Non-public domain: Domains using non-public TLDs (.local, .internal, .home, .lan, .test, .localhost) or bare IP addresses are unlikely to be publicly resolvable. Certificate issuance via ACME will fail because the CA cannot complete domain validation. This check applies to both explicit domains directives and to the host block’s hostname when you use on_demand TLS.