# Example configuration for acme-proxy.
#
# To use these settings, copy this file to `config.toml` (which is gitignored)
# and edit it. Alternatively, since every value below matches the built-in default,
# the server will run with these defaults if no config file is present.
#
# Environment Overrides:
# Any value can be overridden by an environment variable prefixed with `ACME_PROXY_`,
# using `__` (double underscore) as a separator between the table and the key.
# Example: `ACME_PROXY_SERVER__BASE_URL=https://acme.example.com`
# To specify a custom config file path, use `ACME_PROXY_CONFIG=/path/to/config`.
#
# Every list-valued key takes a comma-separated value from the environment,
# e.g. `ACME_PROXY_CHALLENGE__ENABLED=http-01,dns-01`. Items are trimmed, and an
# empty item is refused. A regex containing a literal comma — a quantifier such
# as `{2,3}` — can only be written in this file.
#
# Profiles:
# The server serves ACME only through the [profiles.<name>] tables at the bottom
# of this file — at least one is required, or startup fails. Each is mounted at
# /profile/<name>, so a profile named `default` answers at
# {base_url}/profile/default/directory. See that section for what a profile
# inherits and what it can override.
#
# ---------------------------------------------------------------------------
# Sections in this file, in order. Sections marked [per-profile] may also
# appear inside a [profiles.<name>] block, where they override the value set
# here; everything else is process-wide, one setting for the whole server.
#
# [database] the SQLite file or PostgreSQL server
# [server] listen socket, public URL, admission
# [server.tls] HTTPS on the ACME listener
# [admin] the web admin listener and its sessions
# [admin.notify] operator security notifications
# [admin.filter] who may reach the admin listener
# [admin.tls] HTTPS on the admin listener
# [nonce] replay-nonce freshness
# [audit] reverse lookups and trail retention
# [jobs] the durable background-work queue
# [metrics] the Prometheus listener
# [logging] filter, format, target
# [order] [per-profile] the order object's lifetime
# [signer] [per-profile] how a certificate is obtained
# [signer.local_ca] the embedded CA
# [signer.local_ca.subject] its self-generated certificate's subject
# [signer.local_ca.pkcs11] its key in a token (--features hsm)
# [signer.relay] relaying to an upstream ACME CA
# [signer.relay.eab] upstream EAB bootstrap credential
# [signer.relay.dns01] solving the upstream's DNS-01
# [signer.relay.dns01.rfc2136] the TSIG-authenticated DNS update
# [signer.relay.dns01.propagation] waiting before the upstream looks
# [signer.custom] shelling out to a script
# [challenge] [per-profile] how control of a name is proven
# [challenge.http_01] (there is no [challenge.dns_01])
# [challenge.tls_alpn_01]
# [filter] [per-profile] who may ask, and for what
# [filter.check.<name>] one named check, of one `type`
# [filter.rule.<name>] a condition over checks, and its effect
# [ipam] [per-profile] the inventory `ipam` checks read
# [ipam.netbox]
# [ipam.phpipam]
# [ipam.custom]
# [eab] [per-profile] External Account Binding
# [meta] [per-profile] directory `meta` members
# [notify] [per-profile] outbound notifications
# [notify.email]
# [notify.expiry] the periodic expiry digest
# [notify.webhook.<name>] one table per named HTTP target
# [notify.custom.<name>] one table per named script hook
# [dns] the resolver every lookup goes through
# [proxy] the forward proxy outbound clients use
# [profiles.<name>] an ACME endpoint — at least one required
#
# The eight [per-profile] sections are exactly those a profile may override,
# and inheritance is per *key*, not per section: a profile setting only
# challenge.bypass keeps the global challenge.enabled. Arrays replace
# wholesale, never append.
# ---------------------------------------------------------------------------
[database]
# Database connection URL. This database persists ACME orders and nonces.
# The scheme picks the backend: `sqlite://<path>` for a file (created on first
# use), or `postgres://user:pass@host/db` for a server, which must already
# exist and needs `acme-proxy migrate` run against it once. PostgreSQL is what
# a multi-node deployment needs; SQLite is not safe across hosts.
url = "sqlite://sqlite.db" # ACME_PROXY_DATABASE__URL
[server]
# Network address the server binds to. Controls which interfaces are accessible.
bind_address = "[::]:3000" # ACME_PROXY_SERVER__BIND_ADDRESS
# Public base URL advertised in the ACME directory and used for JWS validation.
base_url = "http://localhost:3000" # ACME_PROXY_SERVER__BASE_URL
#
# Admission control for the ACME endpoints. GET /health is deliberately outside
# all of it: a health probe is asked for exactly when the server is saturated.
#
# How many ACME requests may be in flight at once. Past this, a request waits
# admission_wait_ms for a slot and is then refused with 503 + Retry-After rather
# than queued — an unbounded queue only converts a slow server into a backlog of
# clients that gave up long ago but still cost a slot when their turn comes.
max_concurrent_requests = 100 # ACME_PROXY_SERVER__MAX_CONCURRENT_REQUESTS
# How long to wait for a slot before refusing. Long enough to absorb a burst,
# short enough that the queue can never be deeper than this in time.
admission_wait_ms = 50 # ACME_PROXY_SERVER__ADMISSION_WAIT_MS
# Whole-request deadline. It must exceed every hook that still runs inside a
# request, or work that was going to succeed is cut off and reported to the
# client as a server failure. Challenge validation runs in the job queue, so
# that is now only signer.custom.timeout_ms, and only while a custom signer
# answers `GET /crl` (supports_crl) or renewal information
# (supports_renewal_info); startup refuses a configuration where it does not.
request_timeout_ms = 60000 # ACME_PROXY_SERVER__REQUEST_TIMEOUT_MS
# Largest request body accepted, in bytes. An ACME body is a JWS carrying at
# most a CSR; the default here replaces axum's implicit 2 MiB.
max_body_bytes = 131072 # ACME_PROXY_SERVER__MAX_BODY_BYTES
[server.tls]
# Serve HTTPS on server.bind_address instead of cleartext HTTP. There is one
# listener, not two: enabling this replaces the cleartext one. RFC 8555 §6.1
# expects HTTPS, so leaving this off means terminating TLS in front of the
# server — or accepting that ACME traffic is in the clear.
# Remember to change server.base_url to https:// as well: the JWS `url` check is
# a string comparison, and a stale http:// base refuses every signed request.
enabled = false # ACME_PROXY_SERVER__TLS__ENABLED
# Certificate chain (leaf first) and its private key, both PEM. When either file
# is missing, a self-signed certificate for the host of server.base_url is
# generated and written on startup — as the local CA and sqlite.db do. The
# generated key is created 0600; a supplied one only earns a warning.
cert_path = "server.pem" # ACME_PROXY_SERVER__TLS__CERT_PATH
key_path = "server.key" # ACME_PROXY_SERVER__TLS__KEY_PATH
# Budget for one TLS handshake. A client that stalls part-way through is dropped
# once it elapses; handshakes run concurrently, so this never delays anyone else.
handshake_timeout_ms = 10000 # ACME_PROXY_SERVER__TLS__HANDSHAKE_TIMEOUT_MS
[admin]
# The web admin interface: a SECOND listener, on its own socket, serving no
# ACME. Off by default — a certificate authority does not grow a management
# surface because somebody upgraded it. Bootstrap it with
# `acme-proxy admin user create <username>`; there is no sign-up page.
enabled = false # ACME_PROXY_ADMIN__ENABLED
# Loopback on purpose. This listener has no admission control, filters nothing
# until [admin.filter] names a rule, and — until [admin.tls] is on — has no
# transport security; putting it somewhere a client can reach is a decision,
# not a default. Startup REFUSES a non-loopback
# bind while admin.tls.enabled is false, because the session cookie is always
# sent `Secure` and a browser silently declines to store it over plain HTTP on
# anything but localhost: the symptom is "login works, then I am immediately
# logged out", with nothing in any log to explain it.
# To reach it from elsewhere, prefer an SSH tunnel:
# ssh -N -L 3001:127.0.0.1:3001 <host>
bind_address = "127.0.0.1:3001" # ACME_PROXY_ADMIN__BIND_ADDRESS
# The origin the panel is reached at. Load-bearing three times over: the CSRF
# origin check compares against it, a generated self-signed certificate takes
# its host, and the pages build absolute URLs from it — exactly as
# server.base_url does for the ACME listener. Through a tunnel, this stays
# localhost.
base_url = "http://localhost:3001" # ACME_PROXY_ADMIN__BASE_URL
# Absolute session lifetime (12h). Never extended by activity: past it, the
# operator signs in again.
session_ttl_seconds = 43200 # ACME_PROXY_ADMIN__SESSION_TTL_SECONDS
# Idle session lifetime (1h), advanced on use — at most once a minute, so a
# polling page is not a stream of database writes.
session_idle_timeout_seconds = 3600 # ACME_PROXY_ADMIN__SESSION_IDLE_TIMEOUT_SECONDS
# Failed logins allowed from one address per window, then 503-style refusal with
# a Retry-After. The password hash is deliberately expensive (PBKDF2-HMAC-SHA256
# at 600k iterations), so this is an availability control as much as a
# credential one — over the limit, the hash is not computed at all.
login_max_attempts = 5 # ACME_PROXY_ADMIN__LOGIN_MAX_ATTEMPTS
login_window_seconds = 300 # ACME_PROXY_ADMIN__LOGIN_WINDOW_SECONDS
# Require a second factor (TOTP) of every operator.
#
# An operator who already has one is challenged whether this is set or not; what
# this changes is the operator who has NONE — with it on, their next sign-in
# lands on the enrolment page and their session stays half-authenticated until
# they finish. It deliberately does NOT refuse a password-only login outright:
# enrolling needs a session and a session would then need a factor, so turning
# this on with nobody enrolled would brick the panel, including the way in to
# fix it.
#
# Turning it on does not retroactively end sessions that predate it. The lever
# that does is `acme-proxy admin session revoke --all`.
#
# A lost phone is `acme-proxy admin user totp reset <username>` from a shell on
# the host — the same place the first operator was created. There is no
# self-service reset and no sign-up page, by the same reasoning.
require_mfa = false # ACME_PROXY_ADMIN__REQUIRE_MFA
# Largest admin request body. An admin body is a small JSON object or a form,
# never a certificate.
max_body_bytes = 65536 # ACME_PROXY_ADMIN__MAX_BODY_BYTES
# Ceiling on ?limit= for the list endpoints; the default page size is 50.
page_size_max = 200 # ACME_PROXY_ADMIN__PAGE_SIZE_MAX
# Override individual /ui page templates on disk, mirroring
# notify.template_dir: each name is looked for here first and falls back to the
# compiled-in default, so overriding just "layout.html" restyles every page and
# leaves the rest alone. Every template is compiled at startup — a broken
# override refuses to start rather than serving a 500 at three in the morning.
# Empty means the compiled-in defaults.
template_dir = "" # ACME_PROXY_ADMIN__TEMPLATE_DIR
# [admin.notify] — where web-admin operator security events go (a sign-in from
# an unfamiliar address, a correct password then a refused second factor, a
# lockout, a credential change — ASVS V6.3.5 / V6.3.7). Exactly the shape of the
# per-profile [notify] section below (email / webhook / custom / template_dir /
# per-backend events), but process-wide and built only when [admin] is enabled.
# Email delivery goes to each operator's own contact address
# (`acme-proxy admin user create --contact` / `admin user contact`), falling
# back to notify.email.to when an operator has none; notify.email.to may
# therefore be left empty here. Off (empty `enabled`) by default.
# [admin.notify]
# enabled = ["email"] # ACME_PROXY_ADMIN__NOTIFY__ENABLED
# [admin.notify.email]
# smtp_host = "smtp.example.com"
# from = "acme-proxy@example.com"
# events = ["admin_sign_in", "admin_credential_changed"]
# # ACME_PROXY_ADMIN__NOTIFY__EMAIL__EVENTS
#
# Set an operator's address with `acme-proxy admin user contact <name>
# --contact <address>`; startup warns (`admin_notify_contact_missing`) while
# this section is enabled and some operator has none, since their messages then
# fall back to notify.email.to — or nowhere, if that is empty too.
# [admin.filter] — who may reach this listener at all, for a deployment that
# cannot firewall the port on the host (a container). The same keys and check
# syntax as [filter] below, under ACME_PROXY_ADMIN__FILTER__…, evaluated on every
# admin request, /health included. Its own section: nothing is inherited from
# [filter]. Only allowed_ip, path, reverse_dns and custom checks mean anything
# here; the others are refused at startup. Empty rules (the default) filters
# nothing. trusted_proxies is also the only list the sign-in rate limiter
# believes X-Forwarded-For from.
# [admin.filter]
# rules = ["mgmt"] # ACME_PROXY_ADMIN__FILTER__RULES
# trusted_proxies = [] # ACME_PROXY_ADMIN__FILTER__TRUSTED_PROXIES
# [admin.filter.check.mgmt-net]
# type = "allowed_ip"
# allow = ["10.20.0.0/24", "127.0.0.1/32"]
# [admin.filter.rule.mgmt]
# when = "mgmt-net"
# then = "allow"
[admin.tls]
# HTTPS on admin.bind_address instead of cleartext — the same one-listener-not-
# two shape as [server.tls], and the same load-or-generate provisioning. Anything
# but http://localhost needs this on, or the browser will not store the session
# cookie at all (see admin.bind_address above).
enabled = false # ACME_PROXY_ADMIN__TLS__ENABLED
# Certificate chain (leaf first) and its private key, both PEM. When either is
# missing, a self-signed certificate for the host of admin.base_url is generated
# and written on startup. Separate paths from [server.tls] on purpose: the two
# listeners answer to different names and should not share a certificate by
# accident.
cert_path = "admin.pem" # ACME_PROXY_ADMIN__TLS__CERT_PATH
key_path = "admin.key" # ACME_PROXY_ADMIN__TLS__KEY_PATH
# Budget for one TLS handshake, as [server.tls].
handshake_timeout_ms = 10000 # ACME_PROXY_ADMIN__TLS__HANDSHAKE_TIMEOUT_MS
[nonce]
# Lifetime of nonces in seconds. Shorter TTLs improve security against replay
# attacks, while longer TTLs provide more leeway for slow clients.
ttl_seconds = 300 # ACME_PROXY_NONCE__TTL_SECONDS
[audit]
# Traceability and the CA's audit trail.
#
# There is no `enabled` key: the accounts/orders address columns and the
# audit_log table are always written. Recording who asked the CA to sign
# something is what a CA does, not a feature to switch on.
#
# Resolve a PTR record for the client's address and freeze it into the row
# alongside the address. Turn this off on an estate with no usable reverse
# zone — every lookup would fail, every *_ptr column would end up NULL anyway,
# and all that would be left is the round trip.
reverse_dns = true # ACME_PROXY_AUDIT__REVERSE_DNS
# Budget for one PTR lookup. Deliberately small: this runs inside a request
# that has already done its real work, so a slow nameserver must cost a NULL in
# one column, never latency on issuance.
reverse_dns_timeout_ms = 2000 # ACME_PROXY_AUDIT__REVERSE_DNS_TIMEOUT_MS
# Delete audit_log rows older than this many days. 0 keeps everything for ever,
# which is the right default for a trail whose value is that it is complete;
# set it where a retention policy says otherwise. `acme-proxy audit cleanup
# --older-than <days>` is the same sweep run by hand.
retention_days = 0 # ACME_PROXY_AUDIT__RETENTION_DAYS
[jobs]
# The durable background-work queue: the `jobs` table plus the one runner that
# drains it. This is how the server finishes work it has already promised a
# client — an order answered `processing` is owed a certificate — so there is no
# `enabled` key. What is tunable is how hard and how long it tries.
#
# How often the runner looks for work nobody woke it for. This bounds only
# *scheduled* work: a backoff coming due, a periodic sweep firing. An enqueue
# wakes the runner directly, so a relay queued by finalize starts immediately
# whatever this says.
poll_interval_ms = 1000 # ACME_PROXY_JOBS__POLL_INTERVAL_MS
# How many jobs may run at once. A restart after an upstream outage that left a
# few thousand orders in flight would otherwise become a few thousand concurrent
# pollers against one CA, which is how a recoverable backlog turns into a
# rate-limit ban.
max_concurrent = 8 # ACME_PROXY_JOBS__MAX_CONCURRENT
# How many attempts a job gets before it is retired permanently. Counted when
# the job is claimed, so one that kills the process still exhausts its budget
# instead of crash-looping. With the base below, five attempts span roughly
# seven and a half minutes. Unlike the other keys here this one is frozen onto
# each row as it is queued, so a change applies to new work rather than to a
# backlog already waiting.
max_attempts = 5 # ACME_PROXY_JOBS__MAX_ATTEMPTS
# The first retry delay; each subsequent one doubles, up to the ceiling below.
retry_base_seconds = 30 # ACME_PROXY_JOBS__RETRY_BASE_SECONDS
# Where the doubling stops. An hour sits well under the default order lifetime,
# which keeps a job's own deadline the binding constraint rather than this.
retry_max_seconds = 3600 # ACME_PROXY_JOBS__RETRY_MAX_SECONDS
# The default budget for one attempt, and therefore how long a claim is held
# before another runner may take the row. A handler needing a different one says
# so itself; the relay asks for signer.relay.poll_timeout_secs.
lease_seconds = 300 # ACME_PROXY_JOBS__LEASE_SECONDS
# Delete settled job rows older than this many days. Unlike audit.retention_days
# this defaults to non-zero: a finished job is a receipt, not evidence, and the
# trail that has to be complete is audit_log's. 0 keeps everything for ever and
# stops the sweep being scheduled at all.
retention_days = 7 # ACME_PROXY_JOBS__RETENTION_DAYS
[metrics]
# The Prometheus exposition, on a listener of its own -- a third socket, not a
# route on the ACME or admin one. That is what makes the port the access
# control: a scrape carries no session and needs none, because reaching the port
# at all is the permission. Firewall it to your Prometheus host.
#
# Off by default: a certificate authority should not open a new socket because
# somebody upgraded it.
enabled = false # ACME_PROXY_METRICS__ENABLED
# Beside the other two -- server on 3000, admin on 3001, this on 3002. Unlike
# admin.bind_address, a non-loopback value is neither refused nor warned about:
# there is no cookie here, and a port reachable from a Prometheus host on
# another machine is the intended deployment. Startup refuses a value equal to
# server.bind_address or admin.bind_address, since only one could then bind.
bind_address = "127.0.0.1:3002" # ACME_PROXY_METRICS__BIND_ADDRESS
[logging]
# This section is the SERVER's log stream. The admin subcommands emit nothing at
# all unless asked, with `--log-level <level>` or a non-empty RUST_LOG, and what
# they then emit goes to stderr so stdout stays what a script parses.
#
# Log level filtering (EnvFilter format). Last of three layers: a `--log-level`
# on the command line outranks RUST_LOG, which outranks this.
filter = "acme_proxy=info" # ACME_PROXY_LOGGING__FILTER
# Enable structured JSON logging for integration with centralized log aggregators.
json_format = false # ACME_PROXY_LOGGING__JSON_FORMAT
# Where records are written: "stdout" or "stderr". Anything else fails at startup.
target = "stdout" # ACME_PROXY_LOGGING__TARGET
# ANSI colour in the human-readable format. Ignored when json_format is on.
ansi = true # ACME_PROXY_LOGGING__ANSI
# Span lifecycle records: "none", "close" or "full". "close" emits one record per
# span as it ends, carrying the time spent busy and idle inside it.
span_events = "none" # ACME_PROXY_LOGGING__SPAN_EVENTS
# JSON only: lift a record's own fields to the top level instead of nesting them
# under "fields". What most log pipelines want, at the risk of colliding with the
# format's reserved keys.
flatten_event = false # ACME_PROXY_LOGGING__FLATTEN_EVENT
[order] # [per-profile] — a [profiles.<name>] block may override these
# Validity window for ACME order objects in seconds (RFC 8555 §7.1.3).
# This defines the order's lifetime, not the resulting certificate's validity.
validity_seconds = 604800 # ACME_PROXY_ORDER__VALIDITY_SECONDS
# Most identifiers one newOrder may name. The only other bound is
# server.max_body_bytes, which at ~30 bytes per identifier admits some four
# thousand names in one request -- and each becomes an authorization plus a
# challenge per offered type, inserted in ONE transaction, which SQLite's single
# writer makes a stall for every other write in the process. 100 is what Let's
# Encrypt allows and is far above what a real client asks for; a request past it
# is refused as `malformed`.
max_identifiers = 100 # ACME_PROXY_ORDER__MAX_IDENTIFIERS
# Days an order is kept AFTER it expires, before the daily retention sweep
# deletes it and cascades to its authorizations and challenges. 0 keeps
# everything for ever.
#
# A `valid` order is NEVER swept, whatever its age: its row is how revokeCert
# and the CRL find a certificate by serial, and what RFC 9773 renewal
# information is derived from. Only orders that ended some other way -- invalid,
# or abandoned pending/ready/processing -- are eligible, and only once their own
# `expires` is this many days behind, by which point no client can act on them.
retention_days = 30 # ACME_PROXY_ORDER__RETENTION_DAYS
[signer] # [per-profile] — a [profiles.<name>] block may override these
# The issuance backend to use.
# "local_ca" — this server IS the CA: it signs every certificate itself.
# "relay" — this server RELAYS to an upstream ACME server, becoming a
# client of it. Clients still prove domain control to *this*
# server exactly as before; only the signing moves.
# "custom" — this server delegates issuance/revocation to an external
# script (see [signer.custom] below) — an HSM tool, an
# internal PKI CLI, a cloud KMS wrapper, anything.
backend = "local_ca" # ACME_PROXY_SIGNER__BACKEND
[signer.local_ca]
# Configuration for the persistent Local CA.
# On first run, it automatically generates a self-signed CA if files are missing.
cert_path = "ca.pem" # ACME_PROXY_SIGNER__LOCAL_CA__CERT_PATH
key_path = "ca.key" # ACME_PROXY_SIGNER__LOCAL_CA__KEY_PATH
# Key algorithm for the CA (e.g., "ecdsa-p256").
key_type = "ecdsa-p256" # ACME_PROXY_SIGNER__LOCAL_CA__KEY_TYPE
# The validity period of issued LEAF certificates in days.
# Distinct from the order's lifetime defined in [order].
leaf_validity_days = 90 # ACME_PROXY_SIGNER__LOCAL_CA__LEAF_VALIDITY_DAYS
# Where the CA's certificate revocation list (RFC 5280) is written; served at
# GET /crl and regenerated on every revocation and on every startup.
crl_path = "ca.crl" # ACME_PROXY_SIGNER__LOCAL_CA__CRL_PATH
# Where a relying party can FETCH that CRL, written into every issued leaf as
# cRLDistributionPoints (RFC 5280 §4.2.1.13). Empty emits no extension, which
# is why a leaf says nothing about revocation until this is set.
#
# Nothing derives it: the URL is frozen into every certificate signed while it
# is set, and this server's own CRL lives at {base_url}/profile/<name>/crl
# BEHIND that profile's filter chain — an address-based policy would refuse it
# to exactly the relying parties meant to read it. Name a URL you know is
# publicly reachable (a webroot or CDN copy of crl_path is the usual answer).
# Several entries mean one CRL reachable at several places, not several CRLs.
# http:// is fine and idiomatic here: fetching a signed CRL over TLS invites a
# validation loop. Two profiles sharing one CA share this list too.
crl_distribution_points = [] # ACME_PROXY_SIGNER__LOCAL_CA__CRL_DISTRIBUTION_POINTS
# Where a relying party can fetch THIS CA's own certificate, written into every
# issued leaf as authorityInfoAccess / caIssuers (RFC 5280 §4.2.2.1). Empty
# emits no extension. No OCSP pointer is ever written — this server runs no
# responder.
ca_issuer_urls = [] # ACME_PROXY_SIGNER__LOCAL_CA__CA_ISSUER_URLS
# Where the ISSUING PRIVATE KEY lives.
# "file" — key_path above, loaded or generated. The default, and the only
# behaviour that existed before this key.
# "pkcs11" — a hardware token (YubiKey, HSM, SoftHSM2): the key is created
# inside it and can never be read out; this server sends bytes to
# be signed and gets a signature back. See
# [signer.local_ca.pkcs11] below. Requires a binary built with
# `--features hsm`; configuring it on one without is a startup
# error, never a silent fallback to the file key.
# Anything else is a startup error.
key_source = "file" # ACME_PROXY_SIGNER__LOCAL_CA__KEY_SOURCE
[signer.local_ca.subject]
# Overrides for the autogenerated CA's X.509 Subject (Distinguished Name).
# Every key here is optional; an unset (or empty-string) value is omitted
# from the Subject, except common_name, which falls back to
# "acme-proxy local CA" so the CA always carries one.
#
# Unset by default: the CA's Subject is CommonName-only, exactly as before
# this table existed.
# common_name = "acme-proxy local CA" # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COMMON_NAME
# organization = "Example Corp" # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATION
# organizational_unit = "IT" # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__ORGANIZATIONAL_UNIT
# country = "US" # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__COUNTRY
# state = "California" # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__STATE
# locality = "San Francisco" # ACME_PROXY_SIGNER__LOCAL_CA__SUBJECT__LOCALITY
[signer.local_ca.pkcs11]
# Only read when key_source = "pkcs11" (above), and only available in a build
# with `--features hsm`.
#
# TWO RULES DIFFER FROM THE FILE PATH, both fatal at startup:
#
# 1. The CA is NEVER generated. The private key is created inside the token by
# its own tooling (pkcs11-tool --keypairgen, yubico-piv-tool -a generate),
# which this server cannot do for it, so cert_path must ALREADY hold a
# certificate for that key. key_path is neither read nor written.
# 2. The token key and cert_path are cross-checked. A mismatch — nearly always
# a wrong key_label — stops the server, rather than issuing a fleet of
# certificates that verify nowhere. This is stricter than key_source =
# "file", where nothing checks that key_path matches cert_path.
#
# key_type above is ignored here: nothing is generated, so the algorithm is read
# off the token (P-256 and P-384 are supported).
#
# The module is dlopen'd at runtime, so nothing below affects the build.
#
# module_path = "/usr/lib/softhsm/libsofthsm2.so" # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__MODULE_PATH
# "/usr/lib/libykcs11.so" for a YubiKey (Debian puts it under
# /usr/lib/x86_64-linux-gnu/). Required.
# Which token, by label. PREFER THIS over slot_id: slot numbers are assigned
# dynamically and change across reboots and re-plugs (SoftHSM2 will hand you
# something like 276468771).
# token_label = "acme-ca" # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__TOKEN_LABEL
# Which slot, for a token with no usable label. Read only when token_label is
# empty.
# slot_id = 0 # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__SLOT_ID
# The private key's CKA_LABEL. Required. On a YubiKey these are FIXED by the
# driver — slot 9c is "Private key for Digital Signature" — so this is looked
# up with `pkcs11-tool --module <module> --list-objects --login`, not chosen.
# key_label = "ca-key" # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__KEY_LABEL
# The key's CKA_ID as hex, to disambiguate a token holding several keys under
# one label. Optional; two matching keys and no key_id is a startup error rather
# than a coin flip over which one signs.
# key_id = "01" # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__KEY_ID
# A file holding the user PIN, trailing whitespace trimmed (so a PIN written
# with `echo` works). Warns if world-readable, like ca.key does. This is the
# preferred way to supply it.
# pin_file = "/etc/acme-proxy/hsm.pin" # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__PIN_FILE
# SENSITIVE — the PIN directly. Prefer pin_file or the environment variable; a
# PIN here is a long-lived secret in a file that gets copied around. pin_file
# wins when both are set, and neither set is a startup error.
#
# NOTE: a token blocks after a few wrong attempts (three on a YubiKey PIV
# applet, then you need the PUK). That is why a failed signature is retried at
# most ONCE, and why a stray character in pin_file is worth checking for before
# restarting repeatedly.
# pin = "" # ACME_PROXY_SIGNER__LOCAL_CA__PKCS11__PIN
[signer.relay]
# Only read when backend = "relay" (above).
#
# The upstream ACME server's directory URL — another acme-proxy, a private
# enterprise CA, or a public CA. Required for this backend; an empty value is a
# startup error.
directory_url = "" # ACME_PROXY_SIGNER__RELAY__DIRECTORY_URL
# This proxy's own ACME account key AT the upstream (ECDSA P-256, generated 0600
# if the file is absent). The account URL the upstream assigns is stored beside
# it, in the same path with the extension replaced by ".kid" — so only the FIRST
# startup contacts the upstream to register; later ones read both files locally.
account_key_path = "upstream_account.key" # ACME_PROXY_SIGNER__RELAY__ACCOUNT_KEY_PATH
# Optional contacts sent with the upstream newAccount registration.
contact = [] # ACME_PROXY_SIGNER__RELAY__CONTACT
# How this proxy satisfies the UPSTREAM's own domain-control check. This is a
# second, independent proof cycle: the upstream account belongs to this server,
# so the original client cannot answer it.
# "bypass" — the upstream validates nothing (a private CA that already trusts
# this server, or another acme-proxy with challenge.bypass = true).
# "dns01" — publish the TXT record the upstream asks for, which is what a
# public CA requires. This is the ONE place the server writes DNS
# rather than reading it: the key authorization the upstream
# expects is derived from THIS proxy's account there, so the
# original client cannot answer it even in principle.
# "http01" — serve the file the upstream asks for, from this server's own
# root router at /.well-known/acme-challenge/<token>. This server
# does NOT open a second listener: a reverse proxy must forward or
# redirect that path here from port 80 of every name being issued
# (RFC 8555 §8.3 permits a redirect, so the target need not share
# the name — one `return 301` is enough). There is nothing else to
# configure, hence no [signer.relay.http01] table. Cannot prove a
# wildcard, since nothing answers on the name "*.example.com" —
# use "dns01" for those.
challenge_strategy = "bypass" # ACME_PROXY_SIGNER__RELAY__CHALLENGE_STRATEGY
# How often to poll the upstream order while it resolves. The upstream's own
# Retry-After takes precedence when it sends one.
poll_interval_ms = 2000 # ACME_PROXY_SIGNER__RELAY__POLL_INTERVAL_MS
# Total budget for one upstream issuance. On expiry the local order is marked
# invalid with an explicit timeout reason, never left processing forever.
poll_timeout_secs = 300 # ACME_PROXY_SIGNER__RELAY__POLL_TIMEOUT_SECS
[signer.relay.eab]
# This proxy's own upstream External Account Binding credential (RFC 8555
# §7.3.4), as an alternative to the one-shot
# `acme-proxy upstream register --eab-kid <kid>` command. Both are the SAME
# credential: it authorizes exactly one newAccount call and is useless
# afterwards, since registration itself only ever runs once (guarded by the
# .kid sidecar next to account_key_path).
#
# Putting it here trades away the property that made the admin command the
# only path — a bootstrap secret living in configuration for the life of the
# server — for not needing a separate imperative step, which matters when
# config.toml is already populated by a secrets manager or a templated
# deployment. Once registration succeeds, `serve` logs a
# signer_relay_eab_secret_in_config warning on EVERY startup for as long as
# hmac_key stays non-empty — the same treatment challenge.bypass and
# ipam.netbox.insecure_skip_verify get — so clear it out once
# `acme-proxy upstream show` confirms a kid is stored.
#
# Both empty (the default) is unchanged from before this table existed:
# `serve` then requires `acme-proxy upstream register` if the upstream
# demands EAB. Either field set without the other is a startup error.
kid = "" # ACME_PROXY_SIGNER__RELAY__EAB__KID
# SENSITIVE — prefer the environment variable to a file on disk, like every
# other secret in this file. Base64: url-safe, unpadded url-safe, or standard
# (the same three forms the admin command accepts) — a value that decodes as
# none of them is a startup error.
hmac_key = "" # ACME_PROXY_SIGNER__RELAY__EAB__HMAC_KEY
[signer.relay.dns01]
# Only read when challenge_strategy = "dns01". The only provider is "rfc2136",
# which works with any authoritative server implementing RFC 2136 dynamic
# update (BIND, PowerDNS, Knot, CoreDNS with the `update` plugin) rather than
# tying the server to one cloud vendor's API.
provider = "rfc2136" # ACME_PROXY_SIGNER__RELAY__DNS01__PROVIDER
# DNS alias mode: publish every challenge record at _acme-challenge.<alias>
# instead of _acme-challenge.<domain>, after CNAMEing each domain's
# _acme-challenge name there once. The alias must lie inside rfc2136.zone.
# Empty (the default) writes in each domain's own zone.
challenge_alias = "" # ACME_PROXY_SIGNER__RELAY__DNS01__CHALLENGE_ALIAS
[signer.relay.dns01.rfc2136]
# host:port of the authoritative server that accepts the update.
server = "" # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__SERVER
# The zone being updated, e.g. "example.org." (trailing dot).
zone = "" # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__ZONE
tsig_key_name = "" # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_NAME
# SENSITIVE — but unlike the EAB credential this one is long-lived, since every
# update needs it, so it legitimately lives in configuration. Prefer the
# environment variable to a file on disk. Standard base64, as `dnssec-keygen`
# and BIND emit it — note this is NOT base64url, unlike the EAB secret.
tsig_key_secret = "" # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_SECRET
# hmac-sha256 (default), hmac-sha384 or hmac-sha512. HMAC-MD5 is deliberately
# not offered.
tsig_algorithm = "hmac-sha256" # ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_ALGORITHM
[signer.relay.dns01.propagation]
# What to wait for between publishing the record and asking the upstream to
# validate it. The upstream looks once: a record it cannot see yet makes the
# authorization invalid for good, and the client's order fails with it.
# "none" — trigger right after the update (the default). Right when the
# update server is itself what the CA asks.
# "delay" — sleep delay_secs first. For a provider that accepts an update
# before serving it (an API behind an RFC 2136 bridge), or
# secondaries that lag the primary.
mode = "none" # ACME_PROXY_SIGNER__RELAY__DNS01__PROPAGATION__MODE
# Only read under mode = "delay". Must be less than poll_timeout_secs, which
# bounds the whole attempt — and the delay runs once PER NAME in the order, so
# raise poll_timeout_secs for a multi-name order.
delay_secs = 30 # ACME_PROXY_SIGNER__RELAY__DNS01__PROPAGATION__DELAY_SECS
[signer.custom]
# Only read when backend = "custom" (above). Delegates issuance and
# revocation to an external script — an HSM tool, an internal PKI CLI, a
# cloud KMS wrapper, anything that can read a CSR and hand back a chain.
#
# An empty script_path is a startup error, the same as a `custom` filter check.
script_path = "" # ACME_PROXY_SIGNER__CUSTOM__SCRIPT_PATH
timeout_ms = 5000 # ACME_PROXY_SIGNER__CUSTOM__TIMEOUT_MS
args = [] # ACME_PROXY_SIGNER__CUSTOM__ARGS
#
# Whether the script also answers the "crl" hook (GET /crl) and the
# "renewal_info" hook (RFC 9773 ARI). Both default to false: an unset script
# has nothing useful to say here, and the trait's own defaults already cover
# it sensibly — no CRL published at this endpoint, and "no opinion, compute
# renewal locally" respectively. Turn either on only once the script actually
# implements that hook.
supports_crl = false # ACME_PROXY_SIGNER__CUSTOM__SUPPORTS_CRL
supports_renewal_info = false # ACME_PROXY_SIGNER__CUSTOM__SUPPORTS_RENEWAL_INFO
#
# What the server has already checked before the "issue" hook runs, so the
# script does not have to and must not undo it: the CSR parses, its
# self-signature verifies, every SAN is a DNS name, that set of DNS names is
# exactly ACME_SIGNER_IDENTIFIERS, and the subject common name — if it looks
# like a host name at all — is one of them. That is RFC 8555 §7.4's "the exact
# same set of requested identifiers", and it is the invariant that makes an
# authorization mean anything: the account proved control of those names and no
# others. The script must therefore sign the names in the CSR as they stand and
# must not add names of its own; a certificate for anything else is one this
# server's clients never proved they were entitled to.
#
# The script is invoked once per hook per request — never for a hook whose
# `supports_*` flag is off. It receives context via environment variables:
# ACME_SIGNER_HOOK - "issue", "revoke", "crl" or "renewal_info"
# ACME_SIGNER_ORDER_ID - the local order id (issue hook only)
# ACME_SIGNER_IDENTIFIERS - comma-separated identifier values (issue hook only)
# ACME_SIGNER_REASON - the RFC 5280 revocation reason code, or empty (revoke hook only)
#
# A JSON payload with the full context — including the binary CSR/certificate,
# standard base64 encoded (NOT base64url, unlike ACME's own fields) — is
# always piped to stdin, one object per hook:
# issue: {"hook":"issue","order_id":..,"identifiers":[{"type":"dns","value":".."}],"csr_der_base64":".."}
# revoke: {"hook":"revoke","cert_der_base64":"..","reason":<int|null>}
# crl: {"hook":"crl"}
# renewal_info: {"hook":"renewal_info","cert_der_base64":".."}
#
# Exit codes and stdout, per hook:
# issue - exit 0: stdout is the PEM certificate chain (leaf then
# issuer), used exactly as [signer.local_ca] would produce
# it. Exit 3 is reserved to mean "bad CSR" (maps to a 400
# the client can act on) — deliberately not exit 1, which is
# what a script under `set -e` produces for an unrelated
# bug, and mapping that to a 400 would hide a real failure
# behind a client-facing error instead of a 500. Any other
# non-zero exit is an internal failure (500); the order is
# marked invalid, matching every other backend's BadCsr-vs-
# Internal split. This backend always answers synchronously
# — there is no "processing" state, since a shelled-out
# script has no way to call back into this server later.
# revoke - exit 0 succeeds; the script is responsible for idempotency
# (revoking an already-revoked certificate must not fail),
# the same contract every signer backend has to meet. Any
# non-zero exit is an internal failure (500).
# crl - exit 0: stdout is the DER-encoded CRL, raw bytes, no
# encoding. Empty stdout on exit 0, or any non-zero exit,
# means "no CRL right now" (GET /crl answers 404); a script
# failure here is logged but never fails the request that
# triggered it, since GET /crl is otherwise unauthenticated
# and doesn't gate issuance.
# renewal_info - exit 0 with blank stdout means "no opinion" (falls back to
# this server's local estimate); exit 0 with stdout
# "<start_epoch> <end_epoch>" (whitespace-separated
# integers) gives an explicit renewal window. An optional
# third token is RFC 9773 §4.2's `explanationURL`, served
# verbatim to the client — a page saying why the window has
# that value, e.g. during a mass-revocation event. Anything
# else, or a non-zero exit, is an internal failure.
#
# The script does NOT inherit the server's environment: it is started from an
# empty one holding only the ACME_SIGNER_* variables above plus a minimal PATH
# (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin). This matters
# even more here than for a `custom` filter check — the RFC 2136 TSIG secret
# used by signer.relay.dns01 lives inside this very [signer] section tree, and
# a custom signer script has no business reading it.
#
# One process is spawned per invocation. A script still running when
# timeout_ms expires is killed, not left behind.
#
# Example (commented out, so this file still describes the all-defaults
# configuration, i.e. backend = "local_ca" and script_path = ""):
#
# [signer.custom]
# script_path = "/etc/acme-proxy/signer/issue.sh"
# timeout_ms = 5000
# args = []
# supports_crl = true
# supports_renewal_info = false
[challenge] # [per-profile] — a [profiles.<name>] block may override these
# Which challenge types each new authorization offers, in the order they appear
# in the authorization object. The client picks one of them to satisfy.
# Known names: "http-01", "dns-01", "tls-alpn-01".
#
# A WILDCARD identifier ("*.example.com") can only be proved with "dns-01"
# (RFC 8555 §8.4), so a wildcard authorization offers dns-01 alone — and newOrder
# refuses a wildcard outright when dns-01 is not listed here.
#
# An empty list is a startup error: authorizations would carry no challenges, so
# no client could ever prove control of a name.
#
# Note the spelling: the list uses the RFC names ("http-01"), while the per-type
# tables below use them as TOML keys ("http_01").
enabled = ["http-01"] # ACME_PROXY_CHALLENGE__ENABLED
# Skip validation entirely: triggering a challenge marks it, and its
# authorization, valid with NO network check.
#
# With it on, anyone who can reach this server can obtain a certificate for any
# name, and the [filter] section below is the only access control. The server
# logs a `challenge_validation_bypassed` warning at startup saying so.
#
# This used to default to true — it was the behaviour predating real validation,
# and the default was kept so an existing deployment survived that upgrade. It
# now defaults to FALSE: combined with an empty filter.rules and a default bind
# on every interface, the old default meant a server started with no
# configuration at all was an open certificate authority. Turning validation off
# is a legitimate thing to want (a private CA behind a filter that already knows
# who may ask for what), but it is worth having to ask for.
#
# UPGRADING: a deployment that relied on the old default must now set this
# explicitly, or every challenge will be really validated and issuance will
# start failing for names this server cannot reach.
bypass = false # ACME_PROXY_CHALLENGE__BYPASS
# Budget for one validation attempt. The trigger queues the work and answers
# `processing`, so this bounds a job attempt in the runner rather than a request.
timeout_ms = 5000 # ACME_PROXY_CHALLENGE__TIMEOUT_MS
# How many of one account's challenges may be validating at once; 0 is no limit.
# A trigger over the cap is answered 429 rateLimited with a Retry-After, and the
# challenge stays pending, so the client simply asks again.
max_in_flight_per_account = 32 # ACME_PROXY_CHALLENGE__MAX_IN_FLIGHT_PER_ACCOUNT
[challenge.http_01]
# GET http://<identifier>:<port>/.well-known/acme-challenge/<token>, whose body
# must be the key authorization (RFC 8555 §8.3). Leading and trailing whitespace
# is ignored; anything else must match exactly.
port = 80 # ACME_PROXY_CHALLENGE__HTTP_01__PORT
# Port a redirect may point at when it switches to https. The responder's
# certificate is NOT validated — RFC 8555 §8.3 says so explicitly, because the
# proof is the body, not the transport.
https_port = 443 # ACME_PROXY_CHALLENGE__HTTP_01__HTTPS_PORT
# Follow 3xx responses. Redirecting :80 to https is the most common web server
# configuration, so this is on by default.
#
# It does mean the server issues requests wherever a client points a Location
# header. Three things keep that contained: only http/https and only the two
# ports above are followed, the whole chain shares timeout_ms, and a mismatching
# response body is NEVER echoed back to the client — so a redirect cannot be used
# to read internal pages. On a network where even the request is unwelcome, set
# this to false.
follow_redirects = true # ACME_PROXY_CHALLENGE__HTTP_01__FOLLOW_REDIRECTS
max_redirects = 5 # ACME_PROXY_CHALLENGE__HTTP_01__MAX_REDIRECTS
# Bodies larger than this are refused WITHOUT being compared: the key
# authorization is under a hundred bytes and nothing else belongs at that URL.
max_response_bytes = 4096 # ACME_PROXY_CHALLENGE__HTTP_01__MAX_RESPONSE_BYTES
# "dns-01" has no settings of its own. It looks up TXT records at
# _acme-challenge.<identifier> through the system resolver, with caching disabled
# so a record the client has just published is not masked by a cached negative
# answer. The value must be base64url(SHA256(key authorization)) — the digest,
# not the key authorization itself, which is what http-01 serves.
[challenge.tls_alpn_01]
# TLS handshake with SNI = <identifier> and ALPN "acme-tls/1"; the certificate
# presented must carry exactly one dNSName (the identifier) and the critical
# id-pe-acmeIdentifier extension holding SHA-256 of the key authorization
# (RFC 8737). No separate listener is needed — the responder is the TLS server
# that is already there.
port = 443 # ACME_PROXY_CHALLENGE__TLS_ALPN_01__PORT
[filter] # [per-profile] — a [profiles.<name>] block may override these
# Request filtering: which clients may reach the server, and which names they
# may have certified. Independent of [challenge] above, and the only access
# control left when challenge.bypass is on — with it set, anyone who can reach
# this server can obtain a certificate for any name.
#
# The shape is a small policy engine: NAMED CHECKS, and RULES over them.
#
# - a [filter.check.<name>] is one question about a request — "is this
# address in the management network?", "does the inventory say this address
# owns this name?" — with a `type` saying which question.
# - a [filter.rule.<name>] is a boolean expression over check names plus what
# a match means: `then = "allow"` or `then = "deny"`.
# - `rules` below lists the rules to evaluate, IN ORDER. First match wins.
#
# Everything is a named check, `custom` included, and two checks of one type
# are ordinary — two identifier lists, two script hooks, an address list per
# network. See doc/src/filters/ for the whole chapter.
# Which [filter.rule.<name>] entries to evaluate, and in what order.
# Empty (the default) disables filtering entirely, and the server logs a
# `filter_disabled` warning at startup saying so.
rules = [] # ACME_PROXY_FILTER__RULES
# What happens at a stage where a rule was applicable and none of them matched.
# "deny" or "allow".
#
# Never consulted at a stage no rule applies to, which matters more than it
# sounds: a policy made entirely of identifier-stage rules must not refuse every
# connection before a name has even been mentioned. So a policy of nothing but
# `identifiers` checks still serves /directory and /newNonce.
default = "deny" # ACME_PROXY_FILTER__DEFAULT
# CIDRs of reverse proxies whose forwarded-for header is believed. Leave empty
# when clients connect directly: the header is then ignored, and a client cannot
# claim an allowlisted address by setting it itself.
trusted_proxies = [] # ACME_PROXY_FILTER__TRUSTED_PROXIES
# Header carrying the original client address, read only from a trusted proxy.
forwarded_header = "x-forwarded-for" # ACME_PROXY_FILTER__FORWARDED_HEADER
# --------------------------------------------------------------------- checks
#
# Each [filter.check.<name>] takes a `type` plus that type's own keys. The
# available types are:
#
# allowed_ip which networks an address may be in (CIDR lists)
# reverse_dns the address must have a usable PTR record
# identifiers which names may be certified
# eab which EAB credential the account registered under
# ipam ask the [ipam] inventory whether this address owns this name
# custom shell out to an operator-supplied script
#
# Each name must match ^[a-z0-9-]+$ — lowercase letters, digits and `-`, the
# same restriction profile names have. Reason: a name is also an environment
# variable segment (ACME_PROXY_FILTER__CHECK__<NAME>__...), and the config crate
# lowercases those, so e.g. "MgmtNet" in this file and
# "ACME_PROXY_FILTER__CHECK__MGMTNET__..." would silently become TWO different
# entries instead of one overriding the other. `and`, `or` and `not` are the
# condition language's own words and cannot name a check.
#
# A check defined here but named by no selected rule is never built: it opens no
# connection, validates nothing, and is reported once at startup as
# `filter_check_unused`. That is deliberate — a global [filter] section can hold
# a library of checks and each profile pick the subset its `rules` names.
#
# Setting a key that belongs to another type is a STARTUP ERROR naming both, so
# `script_path` on an `allowed_ip` check stops the server rather than being read
# by nothing.
#
# WHERE A CHECK DECIDES. There are two hook points: the connection (before the
# handler, address only) and the identifiers (at newOrder, and again at finalize
# against the CSR). `allowed_ip` and `custom` answer at both; `identifiers` and
# `ipam` only once names are known; `reverse_dns` at the connection, because a
# PTR plus forward-confirmation exchange at newOrder AND finalize triples the
# lookups for an answer that has not changed. Override with `stages`, e.g.
# stages = ["identifiers"] on a reverse_dns check. Naming a stage the type
# cannot serve is a startup error.
#
# A RULE runs at the INTERSECTION of its checks' stages — never the union,
# because evaluating a rule where one of its checks cannot run would silently
# treat that check as passing. A rule combining a connection-only check with an
# identifiers-only one therefore has no stage at all, and is a startup error
# naming both sides rather than a rule that quietly never fires.
#
# GLOBS AND REGEXES. `allow`/`deny` on the name-matching checks take globs,
# where `*` matches ONE label: "*.example.com" matches "a.example.com", not
# "a.b.example.com" and not "example.com" — list the bare name too, exactly as
# in a certificate. Everything else in a glob is literal. `allow_regex` and
# `deny_regex` take regexes instead, anchored automatically as ^(?:...)$, and
# are unioned with the globs. `deny` wins over `allow`, and an empty `allow`
# imposes no constraint, which gives three usable shapes: allow-only (a strict
# allowlist), deny-only (a blocklist, everything else served), or both (an
# allowlist with holes punched in it).
#
# On `allowed_ip`, `allow`/`deny` are CIDRs or bare addresses instead. Deny-wins
# there is plain membership, not longest-prefix-match: a /32 in `allow` does not
# beat a /8 in `deny`.
# ---------------------------------------------------------------------- rules
#
# `when` is a boolean expression over check names: `and`, `or`, `not` and
# parentheses, with `not` binding tightest, then `and`, then `or`. A parse error
# names the column it gave up at.
#
# `message` replaces the failing check's own wording in the 403 the client sees,
# so an operator can say "ask the network team" instead of exposing which check
# bit. `mode = "warn"` makes a matching rule log `filter_rule_warned` and NOT
# decide, so a tightened policy can be watched in production before it bites —
# a policy of nothing but warn rules falls through to `default`.
#
# A rule whose condition could not be evaluated — an inventory outage, a DNS
# timeout — does not silently drop out. It is remembered, and if the answer the
# policy does reach differs from what that rule would have decided, the whole
# request is a retryable 500 rather than a refusal the client would believe.
# This is also why `mgmt-net or inventory` survives an inventory outage while
# `inventory` alone does not: an `or` whose other side already passed does not
# care what the unknown would have been.
# A worked example, commented out so this file still describes the
# all-defaults configuration. Turning it on needs `rules` above as well:
#
# rules = ["public", "mgmt-bypass", "inventory-owned"]
#
# # Relying parties fetching the CRL are not the ACME clients you allowlisted,
# # and /crl is served by the profile router — so an address-based policy
# # without this makes revocation checking fail for everyone outside it.
# [filter.check.public-paths]
# type = "path"
# allow = ["/crl"]
#
# [filter.check.mgmt-net]
# type = "allowed_ip"
# allow = ["10.0.0.0/8", "192.168.1.0/24"]
# deny = ["10.1.2.0/24"]
#
# [filter.check.corp-names]
# type = "identifiers"
# allow = ["*.corp.example.com", "corp.example.com"]
# deny = ["secret.corp.example.com"]
# allowed_types = ["dns", "cn"]
# allow_wildcards = false
#
# [filter.check.inventory]
# type = "ipam" # consults the [ipam] section below
#
# # Multi-tenancy: `acme-proxy eab create --label tenant-a` mints the credential
# # BEFORE any account exists, so the label is a handle you can write here up
# # front — unlike an account id, which is generated and only knowable after the
# # fact. require_active makes `eab revoke` reach accounts already registered.
# [filter.check.is-tenant-a]
# type = "eab"
# allow = ["tenant-a"]
# kids = []
# require_active = false
#
# [filter.check.has-ptr]
# type = "reverse_dns"
# require_forward_confirm = true
# timeout_ms = 2000
#
# [filter.check.check-network]
# type = "custom"
# script_path = "/etc/acme-proxy/filters/check-network.sh"
# timeout_ms = 5000
# pass_stdin = true
# args = []
#
# [filter.rule.public]
# when = "public-paths"
# then = "allow"
#
# [filter.rule.mgmt-bypass]
# when = "mgmt-net"
# then = "allow"
#
# [filter.rule.inventory-owned]
# when = "corp-names and (inventory or mgmt-net)"
# then = "allow"
# message = "this address owns no such name in the inventory"
# mode = "enforce"
#
# Equivalent via environment variables:
# ACME_PROXY_FILTER__RULES=public,mgmt-bypass,inventory-owned
# ACME_PROXY_FILTER__CHECK__MGMT-NET__TYPE=allowed_ip
# ACME_PROXY_FILTER__CHECK__MGMT-NET__ALLOW=10.0.0.0/8,192.168.1.0/24
# ACME_PROXY_FILTER__RULE__MGMT-BYPASS__WHEN=mgmt-net
# ACME_PROXY_FILTER__RULE__MGMT-BYPASS__THEN=allow
#
# THE CUSTOM SCRIPT CONTRACT. Each script receives context via environment
# variables:
# ACME_FILTER_HOOK - "connection" or "identifiers"
# ACME_FILTER_CHECK_NAME - which [filter.check.<name>] invoked it
# ACME_FILTER_CLIENT_IP - client IP address (or empty if unresolvable)
# ACME_FILTER_METHOD - HTTP method (for connection hook)
# ACME_FILTER_PATH - HTTP path (for connection hook)
# ACME_FILTER_ACCOUNT_ID - account ID (for identifiers hook)
# ACME_FILTER_STAGE - "newOrder" or "CSR" (for identifiers hook)
# ACME_FILTER_IDENTIFIERS - comma-separated values (for identifiers hook)
#
# If pass_stdin is true, a JSON payload with the full context is also piped to
# stdin. Exit code 0 permits the request. Any non-zero code refuses it, using
# the first non-empty line of stdout (or stderr) as the detail message.
# Anything that stops the script answering at all — it could not be spawned, it
# timed out — is an *unknown* rather than a refusal, so a broken hook is a
# retryable 500 and never fails open.
#
# The script does NOT inherit the server's environment: it is started from an
# empty one holding only the ACME_FILTER_* variables above plus a minimal PATH
# (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin). This server
# legitimately carries secrets in its own environment — configuration layers in
# from ACME_PROXY_* variables, which is where the RFC 2136 TSIG key may live —
# and a filter hook has no business reading them. A script needing a value of
# its own should read it from a file, or be given it through `args`.
#
# One process is spawned per custom check per hook invocation, i.e. per
# non-exempt request for the connection hook: keep each script cheap, and keep
# timeout_ms tight. A script still running when timeout_ms expires is killed,
# not left behind.
[ipam] # [per-profile] — a [profiles.<name>] block may override these
# The IP address management inventory an `ipam` filter check consults.
#
# Where an `identifiers` check answers "may anyone here have this name?", an
# `ipam` check answers "may THIS address have THIS name?" — and reads the
# answer from the inventory an estate already keeps instead of a list
# maintained here. Nothing below is read unless a [filter.check.<name>] with
# type = "ipam" is named by a rule in filter.rules.
#
# Matching is EXACT, case-insensitively and ignoring a trailing dot. There is
# no suffix rule and no wildcard expansion: an entry `example.com` does not
# permit `a.example.com`, and a request for `*.example.com` needs that exact
# string in the inventory. Same reasoning as the anchored patterns of an
# `identifiers` check.
#
# An `ip` identifier is permitted when it is the connecting address itself, or
# when it is listed like any other name. A common name ("cn") is skipped, as by
# an `identifiers` check. Any other type is refused: an inventory has nothing to
# say about an email address or a URI, and this filter refuses what it cannot
# confirm.
#
# The inventory being unreachable, answering 5xx, refusing the token or timing
# out is NOT a denial: it is reported as a server error (500) the client can
# retry, so an outage stops issuance rather than permitting everything.
#
# Which product to ask: "netbox", "phpipam", "custom" (an operator script), or
# empty for none. Anything else is a startup error rather than a silent
# fallback. Enabling the `ipam` filter with this empty is also a startup error.
backend = "" # ACME_PROXY_IPAM__BACKEND
# Budget for one whole lookup, however many requests the backend makes to
# answer it — the address query plus, depending on sources, up to four more.
# Applied once around all of them, so a wedged inventory cannot pin a request
# open. Reported as a server error rather than a denial, since the server
# failed to decide. This runs inside newOrder and finalize, so it is also part
# of those requests' worst case.
timeout_ms = 5000 # ACME_PROXY_IPAM__TIMEOUT_MS
[ipam.netbox]
# Where the names come from. Empty, or an unknown entry, is a startup error —
# an inventory trusted for nothing can never permit a name. Order is
# meaningless: the result is a union, unlike filter.rules' evaluation order.
#
# dns_name the address object's own dns_name
# custom_field the custom field named below, on the address object
# device the same field on the device or virtual machine the address
# is assigned to. A FALLBACK, not a union: read only when the
# address object itself carried no value, because a value set
# on the address is the more specific statement and narrowing
# one address of a machine must not be widened again by the
# machine-wide list.
# vip role-tagged service addresses of the same device (see
# vip_roles) — a VIP shared by a keepalived or CARP pair. A
# UNION: a member's own names and the names on the service
# address it answers for are both true at once. One extra
# query: ?device_id=N&role=...
# fhrp the addresses of an FHRP group the client's OWN INTERFACE is
# recorded as a member of. Also a union. Two extra queries, and
# the direction is the point: a group is only ever reached
# through an assignment naming this interface, so there is no
# way to reach a group the client is not in. An interface in no
# group contributes nothing.
#
# The last two are off by default: both widen what a client may certify.
sources = ["dns_name", "custom_field", "device"] # ACME_PROXY_IPAM__NETBOX__SOURCES
# Base URL of the instance. Any path is kept, so a NetBox served under a
# subpath (https://example.com/netbox) works. Required when backend = "netbox".
url = "" # ACME_PROXY_IPAM__NETBOX__URL
# API token. A read-only one is enough, and either generation works: the scheme
# follows the token itself, so there is nothing here to switch. A v2 token —
# the default since NetBox 4.5 — is the whole `nbt_<key>.<secret>` string shown
# once at creation and is sent as `Authorization: Bearer ...`; a legacy v1 token
# is sent as `Authorization: Token ...` and stops being accepted in NetBox 4.7.
# Prefer the environment variable over writing it here: this is a secret, like
# [signer.relay.dns01.rfc2136]'s TSIG key.
token = "" # ACME_PROXY_IPAM__NETBOX__TOKEN
# Custom field holding the extra names an address may have certified. Configure
# it in NetBox as a multi-select or text field on ipam.ipaddress (and, for the
# `device` source, on dcim.device / virtualization.virtualmachine). A single
# string is accepted as well as a list.
custom_field = "acme_domains" # ACME_PROXY_IPAM__NETBOX__CUSTOM_FIELD
# Which NetBox address roles mark a service address, for the `vip` source.
# Read only when `vip` is in sources, so this is *which* roles rather than
# whether to look at all. The role is re-checked on the answer as well as sent
# as a filter: a filter parameter this server got wrong must never degrade into
# "every address on the device".
vip_roles = ["vip", "vrrp", "hsrp", "glbp", "carp", "anycast"] # ACME_PROXY_IPAM__NETBOX__VIP_ROLES
# Extra CA certificates (PEM) to trust on top of the public roots, for a NetBox
# behind an internal PKI. Ignored when insecure_skip_verify is on.
ca_cert_path = "" # ACME_PROXY_IPAM__NETBOX__CA_CERT_PATH
# Skip verification of NetBox's TLS certificate entirely.
#
# Meant as a temporary way out of an expired NetBox certificate, so that issuing
# certificates does not stop while that one is renewed. With it on, the answers
# this server trusts could come from anyone able to intercept the connection —
# unlike the challenge validators, where the certificate is deliberately not
# checked because the proof is elsewhere, here it is the only thing identifying
# the service deciding who may have a name certified. Startup logs an
# `ipam_netbox_tls_verification_disabled` warning for as long as it is set,
# the counterpart of `tls_disabled` and `challenge_validation_bypassed`.
insecure_skip_verify = false # ACME_PROXY_IPAM__NETBOX__INSECURE_SKIP_VERIFY
[ipam.phpipam]
# The same three sources NetBox offers, read from phpIPAM's own shapes:
# dns_name is the address's `hostname`, custom_field is the column named below,
# and device follows `deviceId`. phpIPAM records no address roles and no
# redundancy groups, so naming `vip` or `fhrp` here is a startup error rather
# than a silently ignored setting.
sources = ["dns_name", "custom_field", "device"] # ACME_PROXY_IPAM__PHPIPAM__SOURCES
# Base URL of the instance. Required when backend = "phpipam".
url = "" # ACME_PROXY_IPAM__PHPIPAM__URL
# The API application's identifier — the <app_id> in every phpIPAM API path,
# created under Administration -> API. One path segment: letters, digits, `-`
# and `_`.
app_id = "acme" # ACME_PROXY_IPAM__PHPIPAM__APP_ID
# The application's app code, sent as a bare `token` header (phpIPAM's own
# scheme, not `Authorization`). Set the application's security to "SSL with App
# code". The user/password session-token scheme is not implemented. A secret:
# prefer the environment variable.
token = "" # ACME_PROXY_IPAM__PHPIPAM__TOKEN
# Column holding the extra names an address may have certified, on the address
# and — for the `device` source — on the device. phpIPAM prefixes custom
# columns with `custom_`, so the default carries that prefix. It is a text
# column, so several names are written comma-separated.
custom_field = "custom_acme_domains" # ACME_PROXY_IPAM__PHPIPAM__CUSTOM_FIELD
# Extra CA certificates (PEM) to trust on top of the public roots.
# Ignored when insecure_skip_verify is on.
ca_cert_path = "" # ACME_PROXY_IPAM__PHPIPAM__CA_CERT_PATH
# Skip verification of phpIPAM's TLS certificate entirely — the counterpart of
# [ipam.netbox].insecure_skip_verify, and warned about at startup in the same
# way for as long as it is set.
insecure_skip_verify = false # ACME_PROXY_IPAM__PHPIPAM__INSECURE_SKIP_VERIFY
[ipam.custom]
# Only read when backend = "custom" (above). The inventory is an operator
# script, so this section has no url, no credential and no `sources`: the
# script decides for itself where its answer comes from — a CMDB, a hosts
# file, an LDAP tree, a vendor API this server carries no client for.
#
# The script is told the address twice, so neither a one-line shell script nor
# a Python one has to reach for the channel it finds awkward:
#
# ACME_IPAM_HOOK=names_for the only hook there is
# ACME_IPAM_CLIENT_IP=<address> the resolved, canonicalized client address
# stdin {"hook":"names_for","client_ip":"..."}
#
# It answers with stdout plus an exit code:
#
# exit 0 stdout is the permitted names, ONE PER LINE. Blank lines are
# ignored, and each name is lowercased and stripped of a trailing
# dot, so the script may print whatever form its inventory holds.
# Empty stdout means "recorded, and entitled to nothing".
# exit 3 RESERVED: this inventory holds no record of that address at all.
# A different refusal from the one above, worded its own way, so
# an operator reading a 403 can tell the two apart. stdout is
# ignored.
# anything the script failed, which is the SERVER failing to decide: a
# else retryable 500, never a denial. Same for a script that cannot be
# spawned or that runs past timeout_ms. The first non-empty line of
# stdout (else stderr) is the reason logged.
#
# An empty script_path is a startup error, the same as [signer.custom] and
# [filter.check.<name>] of type "custom".
#
# NOTE `acme-proxy filter explain` really runs the policy, so it executes this
# script — as it does the `custom` filter's.
script_path = "" # ACME_PROXY_IPAM__CUSTOM__SCRIPT_PATH
# Fixed arguments passed before the script is told anything about the request.
# One script can serve several deployments by branching on them.
args = [] # ACME_PROXY_IPAM__CUSTOM__ARGS
#
# There is deliberately NO timeout_ms here: ipam.timeout_ms above is the budget
# the whole lookup runs under, and it is what kills the child at the deadline.
[eab] # [per-profile] — a [profiles.<name>] block may override these
# Require External Account Binding (RFC 8555 §7.3.4) on every newAccount that
# would create an account. A request missing it gets 400
# externalAccountRequired; one that is malformed, unknown, revoked, or fails to
# verify gets 400/401 accordingly. onlyReturnExisting lookups are exempt, since
# they never create an account.
#
# OFF by default, like essentially every public ACME CA (Let's Encrypt itself
# does not require it): this is a policy choice, not a "less secure than it
# looks" default, so — unlike an empty filter.rules or challenge.bypass — it
# earns no startup warning when left off.
#
# Keys are managed with the `eab` admin subcommand (create/list/show/revoke),
# never through this file: a credential must be revocable without a restart.
enabled = false # ACME_PROXY_EAB__ENABLED
[meta] # [per-profile] — a [profiles.<name>] block may override these
# The optional `meta` members of the directory object (RFC 8555 §7.1.1). All
# empty by default; an empty one is omitted from the directory rather than
# advertised blank. Like every other profile section, these can be overridden
# per profile ([profiles.<name>.meta]) — two endpoints on one process can have
# different terms.
# A URL identifying this endpoint's current terms of service (§7.1.1).
#
# Not merely cosmetic: setting it turns on §7.3.3's agreement requirement, so
# newAccount then refuses any request that does not carry
# `termsOfServiceAgreed: true`, answering 403 userActionRequired with a
# `Link: <url>;rel="terms-of-service"` header. Left empty (the default) no
# agreement is asked for, and the account object reflects no
# `termsOfServiceAgreed` member at all — an account created here never agreed
# to anything, and saying `false` would misrepresent that.
terms_of_service = "" # ACME_PROXY_META__TERMS_OF_SERVICE
# An HTTP(S) URL with more information about this ACME server, for a human who
# found the endpoint and wants to know whose it is.
website = "" # ACME_PROXY_META__WEBSITE
# The hostnames this CA recognizes in CAA records (§7.1.1). Advertised only —
# this server performs no CAA checking of its own, since it is built to serve
# private networks where public CAA policy is not the trust anchor.
caa_identities = [] # ACME_PROXY_META__CAA_IDENTITIES
[notify] # [per-profile] — a [profiles.<name>] block may override these
# Operator notifications on lifecycle events: a profile mounting at startup,
# account creation/deactivation, certificate issuance/revocation, and a
# challenge validation failure. Dispatch is fire-and-forget — a notify
# backend can never fail or delay the ACME response that triggered it; any
# delivery failure is only logged.
#
# Which backends are active: "email", "webhook", "custom". Empty (default)
# means no notifications are sent at all.
enabled = [] # ACME_PROXY_NOTIFY__ENABLED
# Which of [notify.webhook]'s entries to POST to, when "webhook" is listed
# above — same shape as custom_enabled below: "webhook" enabled with this
# empty, or an entry named here that isn't defined below, is a startup error.
webhook_enabled = [] # ACME_PROXY_NOTIFY__WEBHOOK_ENABLED
# Which of [notify.custom]'s entries to run, when "custom" is listed above —
# same shape as webhook_enabled above: "custom" enabled with
# this empty, or an entry named here that isn't defined below, is a startup
# error.
custom_enabled = [] # ACME_PROXY_NOTIFY__CUSTOM_ENABLED
# Filesystem directory to look for template overrides in, checked per
# template file before falling back to the compiled-in default — so an
# operator can override just one message (e.g.
# "email/certificate_issued.body.j2") and leave every other one at its
# default. Empty (default) means compiled-in defaults only.
template_dir = "" # ACME_PROXY_NOTIFY__TEMPLATE_DIR
[notify.email]
# SMTP delivery. Required once "email" is in notify.enabled: smtp_host, from
# and to.
smtp_host = "" # ACME_PROXY_NOTIFY__EMAIL__SMTP_HOST
smtp_port = 587 # ACME_PROXY_NOTIFY__EMAIL__SMTP_PORT
smtp_username = "" # ACME_PROXY_NOTIFY__EMAIL__SMTP_USERNAME
# SENSITIVE — prefer the environment variable to a file on disk, like every
# other secret in this file (the RFC 2136 TSIG key, the NetBox token).
smtp_password = "" # ACME_PROXY_NOTIFY__EMAIL__SMTP_PASSWORD
# "starttls" (default), "tls" (implicit TLS) or "none".
smtp_security = "starttls" # ACME_PROXY_NOTIFY__EMAIL__SMTP_SECURITY
from = "" # ACME_PROXY_NOTIFY__EMAIL__FROM
to = [] # ACME_PROXY_NOTIFY__EMAIL__TO
# Which lifecycle events this backend reacts to. Defaults to all of them,
# listed explicitly rather than relying on "empty means all" — every other
# list field in this file treats empty as OFF, so reusing that convention
# here would silently mean "no events" the moment this is set to [].
# "certificates_expiring" is the periodic digest below, which sends nothing
# until notify.expiry.lead_days is set, so leaving it listed costs nothing.
# "admin_sign_in" / "admin_credential_changed" fire only on the process-wide
# [admin.notify] dispatcher (see below), never on a profile's, so listing them
# on a per-profile backend costs nothing either.
events = ["profile_mounted", "account_created", "account_deactivated",
"certificate_issued", "certificate_revoked", "challenge_failed",
"certificates_expiring", "admin_sign_in", "admin_credential_changed"]
# ACME_PROXY_NOTIFY__EMAIL__EVENTS
timeout_ms = 5000 # ACME_PROXY_NOTIFY__EMAIL__TIMEOUT_MS
[notify.expiry]
# The periodic digest of certificates approaching their expiry: ONE message
# per profile per interval_days, listing what lapses inside lead_days.
#
# Deliberately not one message per certificate. A renewal is a new order, so
# the certificate it replaced still expires on schedule — a per-certificate
# reminder therefore fires for every certificate this CA has ever issued, on
# its way out, in exactly the deployments where the automation is working.
# The digest lists the already-replaced ones too, annotated, so the entries
# nobody has renewed are the ones that stand out.
#
# 0 (the default) is OFF — the job is never scheduled at all, the same shape
# as audit.retention_days and jobs.retention_days.
lead_days = 0 # ACME_PROXY_NOTIFY__EXPIRY__LEAD_DAYS
# How often the digest is sent. There is deliberately no per-certificate rate
# limit beside it: the digest is the rate limit.
interval_days = 7 # ACME_PROXY_NOTIFY__EXPIRY__INTERVAL_DAYS
# The most certificates one message lists. The number that matched is carried
# whole regardless, so a truncated digest still says how many it did not name.
max_entries = 50 # ACME_PROXY_NOTIFY__EXPIRY__MAX_ENTRIES
# notify.webhook holds NAMED HTTP targets: one request per event, with the
# URL, the method, the headers and the body all stated here. That is the whole
# point — Slack, Mattermost, Microsoft Teams, Telegram and Matrix differ in
# those four values and nothing else, so a chat provider is configuration
# rather than a backend of its own. Names follow ^[a-z0-9-]+$, the
# [notify.custom] rule and for the same ACME_PROXY_NOTIFY__WEBHOOK__<NAME>__...
# reason.
#
# The body is a MiniJinja template with `message` (the rendered
# "webhook/<event>.j2", overridable through notify.template_dir), `hook` and
# every field of the event in scope. Note the `tojson` filter in the default
# below: a .j2 template has auto-escaping OFF, so a message holding a quote or
# a newline needs it to stay valid JSON.
#
# The URL and the headers routinely carry the credential (a Slack hook id, a
# Telegram bot token, a Matrix access token), so nothing is ever logged of
# either beyond the host and the header names. SENSITIVE, like every other
# secret here: prefer the environment variable.
#
# Example (commented out, so this file still describes the all-defaults
# configuration, i.e. webhook_enabled = [] and no targets configured). See
# doc/src/notifications/webhook.md for the recipe of each provider:
#
# [notify.webhook.slack]
# url = "https://hooks.slack.com/services/T00000000/B00000000/XXXX"
# # ACME_PROXY_NOTIFY__WEBHOOK__SLACK__URL
# method = "POST" # POST (default), PUT or PATCH
# body = '{"text": {{ message | tojson }}}'
# events = ["certificate_issued", "certificate_revoked"]
# timeout_ms = 5000
#
# [notify.webhook.matrix.headers] # ACME_PROXY_NOTIFY__WEBHOOK__MATRIX__HEADERS__AUTHORIZATION
# Authorization = "Bearer syt_xxxxx"
# notify.custom holds NAMED external scripts, the notify-side counterpart of a
# `custom` filter check and signer.custom — the same "shell out to a script"
# contract, and a restriction on names (^[a-z0-9-]+$, for the
# ACME_PROXY_NOTIFY__CUSTOM__<NAME>__... reason).
# This is what lets an operator wire up a channel that is not an HTTP request
# at all (a paging daemon, a local socket, `wall`) — for anything that IS one,
# [notify.webhook] above is the answer and needs no script.
#
# Context via environment variables:
# ACME_NOTIFY_HOOK - event kind, e.g. "certificate_issued"
# ACME_NOTIFY_PROFILE - the profile name
# ACME_NOTIFY_CLIENT_IP - client IP (or empty — always empty for the
# asynchronous relay completion path,
# which has no request in scope at all)
# ACME_NOTIFY_ACCOUNT_ID - account id (or empty, not every event has one)
# ACME_NOTIFY_ORDER_ID - order id (or empty)
# ACME_NOTIFY_CERT_SERIAL - certificate serial (or empty)
# ACME_NOTIFY_IDENTIFIERS - comma-separated identifier values (or empty)
#
# A JSON payload with the full event data, tagged "hook", is always piped to
# stdin. Unlike signer.custom's "issue" hook, there is no reserved exit code
# for a distinct failure kind: this is fire-and-forget, not request-blocking,
# so any non-zero exit is uniformly "delivery failed" (the first non-empty
# line of stdout, or stderr, becomes the logged detail).
#
# The script does NOT inherit the server's environment, for the same reason a
# `custom` filter check and [signer.custom] don't: this process's own
# environment may hold secrets (e.g. notify.email.smtp_password), and a notify
# script has no business reading them.
#
# One process is spawned per enabled script per event. A script still
# running when timeout_ms expires is killed, not left behind.
#
# Example (commented out, so this file still describes the all-defaults
# configuration, i.e. custom_enabled = [] and no scripts configured):
#
# [notify.custom.slack]
# script_path = "/etc/acme-proxy/notify/slack.sh"
# timeout_ms = 5000
# args = []
# events = ["certificate_issued", "certificate_revoked"]
[dns]
# The resolver every DNS lookup this server makes goes through: the dns-01 TXT
# query, the connect target http-01/tls-alpn-01 resolve before reaching out,
# and the PTR/forward lookups filter.reverse_dns makes. One knob rather than
# one per consumer, because they all answer the same question — which
# nameserver this process trusts.
#
# Unset by default: every lookup uses the system configuration
# (/etc/resolv.conf), exactly as before this setting existed. Set it to point
# every lookup at a specific nameserver instead — a split-horizon test network
# being the main reason to.
# resolver = "10.60.0.2:53" # ACME_PROXY_DNS__RESOLVER
[proxy]
# The forward proxy every outbound HTTP client dials through: the upstream CA
# the `relay` signer talks to, the IPAM inventory, the notification webhooks,
# and the http-01/tls-alpn-01 challenge validators (the latter through a
# CONNECT tunnel, since it is TLS rather than HTTP).
#
# Not everything outbound: SMTP (notify.email) and the RFC 2136 DNS updates
# signer.relay.dns01 makes are not HTTP and keep dialling directly. An estate
# whose egress is proxy-only needs a separate route for those two.
#
# Every key is empty by default, which means no proxy at all. Each falls back
# to its conventional environment variable when left empty:
#
# http_url -> $http_proxy
# https_url -> $https_proxy, then $HTTPS_PROXY
# no_proxy -> $no_proxy, then $NO_PROXY
#
# Uppercase HTTP_PROXY is deliberately *not* read (httpoxy, CVE-2016-5385:
# under CGI a client-supplied `Proxy:` header lands in the environment under
# that name). This server is never a CGI process, but Go's net/http dropped
# the variable for the same reason and matching it costs nothing.
#
# The proxy itself is always reached in the clear: https_url names the proxy
# used *for* https targets, and that proxy is normally still http://host:port.
# An https:// value here is a startup error, as is a socks5:// one.
#
# A proxy that is configured but unreachable is an error, every time — there is
# no fallback to a direct connection, since dialling around a controlled egress
# path exactly when the control fails is the opposite of what it is for.
# http_url = "http://proxy.example.com:3128" # ACME_PROXY_PROXY__HTTP_URL
# https_url = "http://proxy.example.com:3128" # ACME_PROXY_PROXY__HTTPS_URL
#
# Credentials go in the URL and are sent as `Proxy-Authorization: Basic`:
# http://user:password@proxy.example.com:3128 (percent-encode anything odd —
# DOMAIN%5Cuser is decoded before the header is built).
#
# Targets that bypass the proxy. An entry is `*` (everything), a domain (which
# also matches everything under it), a `.domain` (the same thing), an address,
# or a CIDR block. A network entry is compared only against a target that is
# already an address literal — a hostname is never resolved to test one, which
# would be a DNS lookup per request and a race with the connect that follows.
# Loopback and `localhost` bypass unconditionally and need no entry.
# no_proxy = ["10.0.0.0/8", ".internal.example"] # ACME_PROXY_PROXY__NO_PROXY
# ---------------------------------------------------------------------------
# Profiles: the ACME endpoints this server actually serves.
# ---------------------------------------------------------------------------
#
# Everything above is the *base*. A profile is one ACME endpoint built from it:
# it inherits every [signer] / [filter] / [ipam] / [challenge] / [eab] /
# [order] / [meta] / [notify] value above, key by key, and overrides only what
# it states. So the
# empty table below is a complete endpoint, identical to the configuration
# above.
#
# At least one enabled profile is required — without one the server has nothing
# to serve, and it says so at startup rather than answering 404 to everything.
#
# Each profile is mounted at /profile/<name>, derived from the name and never
# configured: that namespace is reserved, so an endpoint can never collide with
# a server route (/health) present or future. The name is therefore public API
# — renaming it invalidates every account and order URL its clients hold. Use
# lowercase letters, digits and `-`.
#
# From the environment: ACME_PROXY_PROFILES__<NAME>__<SECTION>__<KEY>, e.g.
# `ACME_PROXY_PROFILES__LE__SIGNER__BACKEND=relay`. A profile that
# overrides nothing still needs one variable to exist at all — set
# `ACME_PROXY_PROFILES__DEFAULT__ENABLED=true`.
[profiles.default]
# Mount this profile. Default true; set it to false to park an endpoint without
# deleting its configuration.
enabled = true # ACME_PROXY_PROFILES__DEFAULT__ENABLED
# A second endpoint, relaying to a real upstream CA while keeping the filters
# and challenge settings from the base above. Commented out because the example
# must match the defaults it documents; uncomment and adapt.
#
# [profiles.le]
# signer.backend = "relay"
# signer.relay.directory_url = "https://acme-v02.api.letsencrypt.org/directory"
# signer.relay.account_key_path = "le_upstream.key"
# challenge.bypass = false