Configuration Reference
This page is the structured reference for every core configuration parameter.
For deep dives into specific subsystems (Signers, Filters, Notifications, EAB), see their dedicated chapters — linked at the bottom, and the place where those sections’ keys are documented.
How configuration is resolved
Sources are layered, lowest precedence first:
- Built-in defaults — everything documented below has one, so an empty
configuration is valid apart from
[profiles]. - A configuration file —
config.tomlin the working directory, or the path inACME_PROXY_CONFIG(the extension may be omitted, in which case the format is inferred). A missing file is not an error. ACME_PROXY_*environment variables —__separates nested keys, a single_separates the prefix.server.tls.enabledisACME_PROXY_SERVER__TLS__ENABLED.
config.toml.example in the repository is the annotated companion to this page:
it lists every key with its default and its environment variable name in
context.
[profiles]is mandatory. Everything else can be left at its default, but the server serves ACME only through profiles and refuses to start without at least one enabled. See[profiles]below.
Every section, and where it is documented
Six sections are large enough to have a chapter of their own, and their keys
are documented there rather than restated here. This page stays the complete
map: every top-level section acme-proxy reads appears below, whether or
not its text lives here. A delegated section’s own sub-tables —
[signer.local_ca.subject], [ipam.netbox], [filter.check.<name>] and the
rest — are listed in the chapter that owns them.
Overridable marks the sections a [profiles.<name>] block may override.
Everything else is process-wide — one setting for the whole server, however many
endpoints it mounts.
| Section | Controls | Overridable | Documented |
|---|---|---|---|
[database] | The SQLite file or PostgreSQL server | no | below |
[server] | Listen socket, public URL, admission control | no | below |
[server.tls] | HTTPS on the ACME listener | no | below |
[admin] | The web admin listener and its sessions | no | below |
[admin.filter] | Who may reach the admin listener | no | below |
[admin.notify] | Operator security notifications | no | below |
[admin.tls] | HTTPS on the admin listener | no | below |
[nonce] | Replay-nonce freshness | no | below |
[audit] | Reverse lookups and retention for the trail | no | below |
[jobs] | The durable background-work queue | no | below |
[metrics] | The Prometheus listener | no | below |
[dns] | The resolver every outbound lookup uses | no | below |
[proxy] | The forward proxy outbound clients dial through | no | below |
[logging] | Filter, format, target | no | below |
[order] | The ACME order object’s lifetime | yes | below |
[meta] | Directory meta members | yes | below |
[profiles.<name>] | An ACME endpoint | — | below |
[signer] | How a certificate is obtained | yes | Signers |
[filter] | Who may ask, and for what | yes | Filters |
[ipam] | The inventory an ipam filter check consults | yes | IPAM |
[challenge] | How control of a name is proven | yes | Challenge Validation |
[notify] | Outbound notifications | yes | Notifications |
[eab] | External Account Binding | yes | EAB |
The criterion for the last six is having a chapter, not being overridable —
[order] and [meta] are overridable and documented here, because neither is
large enough to be worth a page. That is the whole rule; there is nothing
subtler going on.
config.toml.example in the repository carries the same list as a comment
header, with every key in context.
[database]
url (String) — Default: "sqlite://sqlite.db" | Env: ACME_PROXY_DATABASE__URL
Database connection URL, for accounts, orders, certificates and everything else this server keeps. The scheme picks the backend, and any other scheme is refused by name at startup:
sqlite://<path>— a file, created on first use. The default, and what a single-host deployment wants.sqlite:///var/lib/acme-proxy/acme.dbis an absolute path (three slashes).postgres://orpostgresql://— a server, which must already exist: creating a database is an operator’s act, not something a server does to a cluster it was pointed at. Runacme-proxy migrateonce against it.
PostgreSQL is what a multi-node deployment needs. SQLite across processes is fine on one local disk, and not safe on NFS or across hosts — see Deployment. Nothing else changes with the backend: the same binary, the same configuration and the same ACME behaviour.
A PostgreSQL URL usually carries user:password@. The password is never
logged: the startup line and the SIGHUP refusal both print it as
postgres://acme:***@host/db. It is still a credential in a configuration
file, so give that file the permissions it deserves.
This key cannot be changed by a reload — see Reload.
[server]
bind_address (String) — Default: "[::]:3000" | Env: ACME_PROXY_SERVER__BIND_ADDRESS
Network address the server binds and listens to. SIGHUP moves it without
restarting, and an address that cannot be bound refuses the reload rather than
taking the running socket down — see Configuration
Reload.
base_url (String) — Default: "http://localhost:3000" | Env: ACME_PROXY_SERVER__BASE_URL
Public base URL advertised in the ACME directory, with no trailing slash. It is
never derived from the request, and every signed request’s url field is
checked against it (RFC 8555 §6.4) — so behind a reverse proxy, or with TLS
enabled, this must be set to the public URL or every client is rejected.
max_concurrent_requests (Integer) — Default: 100 | Env: ACME_PROXY_SERVER__MAX_CONCURRENT_REQUESTS
How many ACME requests may be in flight at once before the server sheds load.
admission_wait_ms (Integer) — Default: 50 | Env: ACME_PROXY_SERVER__ADMISSION_WAIT_MS
How long a request may wait for a slot before it is refused. Past the limit a
request waits this long and then gets 503 + Retry-After — it is shed, not
queued.
request_timeout_ms (Integer) — Default: 60000 | Env: ACME_PROXY_SERVER__REQUEST_TIMEOUT_MS
Whole-request deadline. It must exceed signer.custom.timeout_ms when that
backend is installed with its crl or renewal_info hook enabled, since those
hooks run inline inside a request; the server refuses to start otherwise. It is
deliberately independent of challenge.timeout_ms and of the script’s issue
and revoke hooks, which run in the job queue — see Challenge
Validation. A relay or custom revocation that a request
queues is waited on for this long, less a second.
max_body_bytes (Integer) — Default: 131072 | Env: ACME_PROXY_SERVER__MAX_BODY_BYTES
Largest request body accepted (128 KiB).
These four keys govern the ACME routes only.
GET /healthis mounted outside all of them.
trusted_proxiesandforwarded_headerare not[server]keys — they live under[filter]. See Filters.
[server.tls]
Full treatment in TLS Termination.
enabled (Boolean) — Default: false | Env: ACME_PROXY_SERVER__TLS__ENABLED
Serve HTTPS on bind_address instead of cleartext — one listener, not two.
base_url is not rewritten for you; set it to https://… yourself or every
signed request fails the §6.4 URL check above.
cert_path (String) — Default: "server.pem" | Env: ACME_PROXY_SERVER__TLS__CERT_PATH
PEM certificate chain, leaf first. A self-signed certificate is generated and
written when either this or key_path is missing.
key_path (String) — Default: "server.key" | Env: ACME_PROXY_SERVER__TLS__KEY_PATH
Path to the private key.
handshake_timeout_ms (Integer) — Default: 10000 | Env: ACME_PROXY_SERVER__TLS__HANDSHAKE_TIMEOUT_MS
Budget for one TLS handshake. Handshakes run concurrently, off the accept path, so this never delays another client.
[admin]
The web admin interface — a second listener, on its own socket, serving no
ACME. Process-wide, so there is no [profiles.<name>].admin: an operator
manages every endpoint this process serves. Full treatment in
Web Admin.
enabled (Boolean) — Default: false | Env: ACME_PROXY_ADMIN__ENABLED
Off by default: a certificate authority should not grow a management surface
because somebody upgraded it. Bootstrap it with acme-proxy admin user create;
there is no sign-up page.
bind_address (String) — Default: "127.0.0.1:3001" | Env: ACME_PROXY_ADMIN__BIND_ADDRESS
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.
Startup refuses a non-loopback bind while admin.tls.enabled is false.
The session cookie is always sent Secure, and a browser silently declines to
store one over plain HTTP on anything but localhost; the symptom would be
“sign-in works, then I am immediately signed out”, with nothing in any log to
explain it. Either enable [admin.tls], or keep the loopback bind and reach it
through an SSH tunnel:
$ ssh -N -L 3001:127.0.0.1:3001 ca.example.com
It is also an error for this to equal server.bind_address.
base_url (String) — Default: "http://localhost:3001" | Env: ACME_PROXY_ADMIN__BASE_URL
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. The
resolved origin is logged at startup (admin_origin_resolved) so a mismatch is
visible before the first refused request.
session_ttl_seconds (Integer) — Default: 43200 (12 h) | Env: ACME_PROXY_ADMIN__SESSION_TTL_SECONDS
Absolute session lifetime. Never extended by activity: past it, the operator signs in again.
session_idle_timeout_seconds (Integer) — Default: 3600 (1 h) | Env: ACME_PROXY_ADMIN__SESSION_IDLE_TIMEOUT_SECONDS
Idle lifetime, advanced on use — at most once a minute, so a polling page is not a stream of database writes. Whichever deadline comes first wins.
login_max_attempts (Integer) / login_window_seconds (Integer) — Defaults: 5 / 300 | Env: ACME_PROXY_ADMIN__LOGIN_MAX_ATTEMPTS, ACME_PROXY_ADMIN__LOGIN_WINDOW_SECONDS
Failed sign-ins allowed from one address per window, then 429 with a
Retry-After. The password hash is deliberately expensive (PBKDF2-HMAC-SHA256
at 600 000 iterations), so this is an availability control as much as a
credential one: over the limit, the hash is not computed at all.
Keyed on the client address. The forwarded-for header is believed only from a
peer listed in admin.filter.trusted_proxies — never from
filter.trusted_proxies, which governs the ACME listener. With no trusted
proxy listed, the limiter behind a reverse proxy counts the proxy.
require_mfa (Boolean) — Default: false | Env: ACME_PROXY_ADMIN__REQUIRE_MFA
Require a second factor (TOTP) of every operator. 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. An operator who already has one is challenged whether this is set or not.
It deliberately does not refuse a password-only sign-in outright: enrolling needs a session and a session would then need a factor, so that would brick the panel including the way in to fix it.
Turning it on does not retroactively end sessions that predate it — acme-proxy admin session revoke --all is the lever that does. While it is on and some
operator has no factor, every start logs admin_mfa_enrolment_pending. See
Operators and sessions.
max_body_bytes (Integer) — Default: 65536 | Env: ACME_PROXY_ADMIN__MAX_BODY_BYTES
Largest admin request body. An admin body is a small JSON object or a form, never a certificate.
page_size_max (Integer) — Default: 200 | Env: ACME_PROXY_ADMIN__PAGE_SIZE_MAX
Ceiling on ?limit= for the list endpoints; the default page size is 50. A
larger request is clamped, not refused.
template_dir (String) — Default: "" | Env: ACME_PROXY_ADMIN__TEMPLATE_DIR
Override individual page templates on disk, mirroring notify.template_dir.
Empty means the compiled-in defaults. The override is per file: a directory
holding only layout.html restyles the chrome of every page and leaves the
other fifty-five at their defaults. Every template is compiled at startup, so a
broken override refuses to start rather than serving a 500 later. Applies to
the /ui pages only; the JSON API has nothing to template. See
Customizing the Panel.
[admin.filter]
Who may reach the admin listener at all — for the deployment that cannot put a
host firewall in front of the port, such as a container. The same policy engine
and the same keys as the ACME listener’s
[filter] (rules, default,
trusted_proxies, forwarded_header, [admin.filter.check.<name>],
[admin.filter.rule.<name>]), under
ACME_PROXY_ADMIN__FILTER__…, with four differences:
- It is its own section. Nothing is inherited from the global
[filter], which is the ACME profiles’ base: an edit made for one listener must not open or shut the other. - Only the connection stage exists here, so only
allowed_ip,path,reverse_dnsandcustomchecks are accepted. Anidentifiers,eaboripamcheck, or a rule whose checks were moved to the identifier stage withstages, is refused by name at startup and on reload. - Empty
rulesfilters nothing, and logs no warning. trusted_proxiesalso keys the login limiter (seeadmin.login_max_attempts): it is the one list the admin listener believes a forwarded-for header from, and it applies whether or not a rule is written.
Every request is evaluated, /health included. A refusal is 403 — the admin
API’s JSON error (access_denied) under /api, the HTML error page elsewhere —
and is logged as filter_request_blocked with listener = "admin". See
Web Admin — Restricting who can reach it.
[admin.notify]
A whole [notify] section, process-wide, built only while [admin] is
enabled. It delivers the admin_sign_in / admin_credential_changed operator
security events (ASVS V6.3.5 / V6.3.7). Every key is the one documented for the
per-profile [notify] — under
ACME_PROXY_ADMIN__NOTIFY__… — with one difference: [admin.notify.email].to
may be empty, since each event carries the affected operator’s own contact
address as its recipient. See
Web Admin — Security notifications.
[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.
enabled (Boolean) — Default: false | Env: ACME_PROXY_ADMIN__TLS__ENABLED
Anything but http://localhost needs this on, or the browser will not store the
session cookie at all.
cert_path / key_path (String) — Defaults: "admin.pem" / "admin.key" | Env: ACME_PROXY_ADMIN__TLS__CERT_PATH, ACME_PROXY_ADMIN__TLS__KEY_PATH
PEM chain (leaf first) and its private key. When either is missing, a
self-signed certificate for the host of admin.base_url is generated and
written at startup; the generated key is created 0600. Separate paths from
[server.tls] on purpose — the two listeners answer to different names and
should not share a certificate by accident.
handshake_timeout_ms (Integer) — Default: 10000 | Env: ACME_PROXY_ADMIN__TLS__HANDSHAKE_TIMEOUT_MS
As [server.tls].
[challenge]
Which challenge types each new authorization offers, whether they are validated
at all, and the per-type keys under [challenge.http_01] and
[challenge.tls_alpn_01].
Documented in full in Challenge Validation — this section is a per-profile subsystem with its own chapter, so its keys live there rather than being restated here.
[order]
validity_seconds (Integer) — Default: 604800 (7 days) | Env: ACME_PROXY_ORDER__VALIDITY_SECONDS
Lifetime of the ACME order object before it expires. This is housekeeping
for the order resource, not the issued certificate’s validity — that is
signer.local_ca.leaf_validity_days, or whatever the delegating backend
decides.
max_identifiers (Integer) — Default: 100 | Env: ACME_PROXY_ORDER__MAX_IDENTIFIERS
Most identifiers a single newOrder may name. A request naming more is refused
with urn:ietf:params:acme:error:malformed.
The only bound before this was server.max_body_bytes (128 KiB), which at
roughly thirty bytes per identifier admits some four thousand names in one
request. Every one of them becomes an authorization plus a challenge per offered
challenge type, all inserted in a single transaction — and SQLite has one
writer, so that transaction stalls every other write in the process while it
runs. 100 is what Let’s Encrypt allows and is far above what a real client
asks for; the point is that there is a ceiling.
Refused as malformed rather than rateLimited deliberately: the order is
malformed for this server whenever it is sent, so rateLimited — which tells a
client to come back later — would be a lie.
retention_days (Integer) — Default: 30 | Env: ACME_PROXY_ORDER__RETENTION_DAYS
Days an order is kept after it expires, before the daily order_sweep
deletes it. The authorizations and challenges beneath it go with it, through the
schema’s ON DELETE CASCADE. 0 keeps everything for ever.
A valid order is never swept, whatever its age. Its row is how
revokeCert and the CRL resolve a certificate by serial, and what RFC 9773
renewal information is derived from — deleting one would make an issued
certificate unrevokable and unrenewable, which is a far worse outcome than a
large table. Only orders that ended some other way are eligible: invalid, or
an abandoned pending/ready/processing order past its own expires, at
which point no client can act on them either.
Nothing pruned orders before this key existed, so the table and its children
grew for the life of a deployment — one order plus one authorization per
identifier plus one challenge per offered type, on a default configuration where
newAccount is open to anyone.
Per-profile like the rest of [order]. The sweep is a single job handler
(JobRegistry refuses two handlers for one kind) that applies each mounted
profile’s own value to that profile’s rows, emitting one order_reaper_swept
line per profile.
[nonce]
ttl_seconds (Integer) — Default: 300 | Env: ACME_PROXY_NONCE__TTL_SECONDS
Freshness window for JWS anti-replay nonces. Expired nonces are swept on an interval for the life of the process.
[audit]
Traceability and the CA’s audit trail. Process-wide, not per-profile — the
trail describes the CA, not one of its endpoints, so this section may not appear
under [profiles.<name>].
There is deliberately no enabled key. The address columns on accounts and
orders, 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. The only
thing here that can be turned off is the reverse lookup.
reverse_dns (Boolean) — Default: true | Env: ACME_PROXY_AUDIT__REVERSE_DNS
Resolve a PTR record for the client’s address and freeze it into the row beside
the address. Turn it 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. Lookups go through dns.resolver like every other
DNS query this server makes.
reverse_dns_timeout_ms (Integer) — Default: 2000 | Env: ACME_PROXY_AUDIT__REVERSE_DNS_TIMEOUT_MS
Budget for one PTR lookup. Deliberately small: this runs inside a request that
has already done its real work, so a slow nameserver costs a NULL in one
column rather than latency on issuance. Every failure is a NULL, never a
refused request.
retention_days (Integer) — Default: 0 | Env: ACME_PROXY_AUDIT__RETENTION_DAYS
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.
Any non-zero value schedules a daily audit_sweep job beside the nonce one,
running the same DELETE as acme-proxy audit cleanup --older-than <days>.
See Audit Trail.
[jobs]
The durable background-work queue: the jobs table plus the one runner that
drains it. Process-wide, not per-profile — there is one queue and one
runner for the process, so this section may not appear under
[profiles.<name>].
There is deliberately no enabled key. The queue is how the server finishes
work it has already promised a client: an order answered processing is owed a
certificate. Switching it off would not disable a feature, it would strand the
orders. What is tunable is how hard and how long the server tries.
Most of what the server does after answering a request runs here, so this section’s reach is wider than the name suggests:
- Client-visible work — challenge validation (
challenge_validate), issuance (signer_issue, andsigner_relay_issueunder therelaybackend), revocation through a relay or script (signer_revoke), and a local CA’s CRL signing (local_ca_crl_regenerate). - Deliveries — every notification
(
notify_deliver) and the expiry digest (notify_expiry_digest). - Periodic sweeps — expired nonces, orders past
order.retention_days,audit.retention_days, expired admin sessions, stalehttp-01tokens, the daily CRL refresh (local_ca_crl_sweep), and this queue’s ownretention_days.
A runner that is not running is a server that validates, issues, sweeps and
notifies nothing — job_runner_started is the line that says it is. With role
processes, only a worker process runs one.
poll_interval_ms (Integer) — Default: 1000 | Env: ACME_PROXY_JOBS__POLL_INTERVAL_MS
How often the runner looks for work nobody woke it for. This bounds only
scheduled work — a backoff coming due, a periodic sweep firing. Queueing a job
wakes the runner directly, so a relay queued by finalize starts immediately
whatever this says, which is what keeps a polling ACME client from waiting on a
tick.
max_concurrent (Integer) — Default: 8 | Env: ACME_PROXY_JOBS__MAX_CONCURRENT
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_attempts (Integer) — Default: 5 | Env: ACME_PROXY_JOBS__MAX_ATTEMPTS
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 rather than crash-looping. With the 30-second base below and doubling, five attempts span roughly seven and a half minutes.
Unlike every other key here, this one is frozen onto each row as it is queued rather than read afresh each attempt, so raising it applies to work queued from then on and not to a backlog already waiting.
retry_base_seconds (Integer) — Default: 30 | Env: ACME_PROXY_JOBS__RETRY_BASE_SECONDS
The first retry delay; each subsequent one doubles. Longer than any single upstream round trip, so a retry is not simply the same failure again, and short enough that a blip clears inside one client poll cycle.
retry_max_seconds (Integer) — Default: 3600 | Env: ACME_PROXY_JOBS__RETRY_MAX_SECONDS
Where the doubling stops, and a real ceiling — the jitter applied to each delay only ever subtracts, so no retry is scheduled past this. An hour sits well under the default order lifetime, which keeps a job’s own deadline the binding constraint rather than this.
lease_seconds (Integer) — Default: 300 | Env: ACME_PROXY_JOBS__LEASE_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 signer asks for its own signer.relay.poll_timeout_secs.
retention_days (Integer) — Default: 7 | Env: ACME_PROXY_JOBS__RETENTION_DAYS
Delete settled job rows older than this many days. Unlike
audit.retention_days this defaults to a non-zero value: 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.
[metrics]
The Prometheus exposition, served on a listener of its own — a third socket beside the ACME and admin ones, not a route on either. Process-wide, not per-profile: there is one counter set for the process, and the endpoint is a dimension of it rather than something each endpoint configures for itself.
The separate port is what settles the access question. A scrape carries no session and needs none, because reaching the port at all is the permission — so the control is your firewall, not a credential this server checks. Putting it on the ACME listener would have meant an unauthenticated route on a public socket; putting it on the admin listener would have meant an auth exemption on a listener whose rule is that every route but sign-in needs a session, plus coupling metrics to the panel being enabled.
There is deliberately no path key — /metrics is what every scrape
configuration already assumes, and this listener serves nothing else. There is
no [metrics.tls] either, unlike [server.tls] and
[admin.tls]: those carry a client’s signed requests and an
operator’s session cookie, while a scrape carries no credential and the
exposition holds no secret.
What is exposed, and how to point Prometheus at it, is in Monitoring.
enabled (Boolean) — Default: false | Env: ACME_PROXY_METRICS__ENABLED
Bind the metrics listener and serve GET /metrics. Off by default, the same
posture [admin] takes: a certificate authority should not open a new
socket because somebody upgraded it. Off means no socket at all, rather than one
answering 404.
bind_address (String) — Default: 127.0.0.1:3002 | Env: ACME_PROXY_METRICS__BIND_ADDRESS
The socket the exposition is served on — beside the other two, server on
3000 and admin on 3001.
Unlike admin.bind_address, a non-loopback value is neither refused
nor warned about. The admin listener refuses one without TLS because its
cookie is always Secure, which a browser silently declines to store over plain
HTTP, so the symptom would be an unexplained sign-out loop. Nothing here has a
cookie, and a port reachable from a Prometheus host on another machine is
exactly the intended deployment.
A value equal to server.bind_address, or to admin.bind_address while the
panel is enabled, is refused — at startup and on a reload alike — since only
one of the three could then bind.
Both keys reload: SIGHUP moves this listener, or switches it on and off,
without restarting. The new socket is bound before anything is published, so an
address that cannot be bound refuses the reload and leaves the running one
serving. See Configuration Reload.
[dns]
resolver (String) — Default: unset (system configuration, i.e. /etc/resolv.conf) | Env: ACME_PROXY_DNS__RESOLVER
host:port of the nameserver every DNS lookup this server makes goes
through: the dns-01 TXT query, the connect target that http-01/tls-alpn-01
resolve before reaching out, and filter.reverse_dns’s PTR and forward lookups.
The shared resolver is deliberately uncached, so a TXT record published
moments before a challenge is triggered is not defeated by a cached negative
answer. filter.reverse_dns is the one exception and keeps its own cached
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 and tls-alpn-01 challenge validators — the last through a CONNECT
tunnel, since it is TLS rather than HTTP.
Not everything outbound. SMTP (notify.email) and the RFC 2136 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. There is no
enabled key — the presence of a URL is the switch.
http_url (String) — Default: "" | Env: ACME_PROXY_PROXY__HTTP_URL
Proxy for http:// targets, e.g. http://proxy.example.com:3128. Falls back to
$http_proxy when empty. A cleartext target is forwarded rather than
tunnelled: the request line carries the whole URL, which is what RFC 9112
§3.2.2’s absolute-form is for.
https_url (String) — Default: "" | Env: ACME_PROXY_PROXY__HTTPS_URL
Proxy for https:// targets, reached by CONNECT. Falls back to
$https_proxy, then $HTTPS_PROXY.
Normally the same http://proxy.example.com:3128 as http_url: this key names
the proxy used for https targets, not a proxy spoken to over https. An
https:// value is a startup error rather than a second TLS layer with no trust
anchor configured for it, and so is a socks5:// one.
The two keys are independent on purpose. An estate that proxies only its TLS egress is ordinary, and “it worked for http and silently did nothing for https” is the failure a single key would produce.
no_proxy (Array<String>) — Default: [] | Env: ACME_PROXY_PROXY__NO_PROXY
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. Matching is case-insensitive and ignores a trailing root dot.
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 mean a DNS lookup on every outbound request and a race with the connect that follows.
An entry carrying a port is a startup error. Matching is on the host, and an entry that silently ignored half of itself is worse than one that is refused.
Loopback and localhost bypass unconditionally, before this list is consulted.
That rule exists because of the environment fallback: an operator’s inherited
shell http_proxy must not route this server’s own loopback traffic through a
corporate proxy, and the failure that would cause carries no signal at all.
Environment fallback
Each key falls back to its conventional variable when left empty:
| key | then | then |
|---|---|---|
http_url | $http_proxy | — |
https_url | $https_proxy | $HTTPS_PROXY |
no_proxy | $no_proxy | $NO_PROXY |
An empty string counts as unset in both sources, so a ${VAR:-} shell default
does not become a proxy at the empty URL.
Uppercase HTTP_PROXY is deliberately not read. Under CGI a client-supplied
Proxy: request header lands in the environment under exactly that name
(httpoxy, CVE-2016-5385 and its siblings). This server is never a CGI process,
so the vector does not reach it — but Go’s net/http dropped the variable for
this reason, matching it costs nothing, and honouring a variable purely because
everything else does is the kind of decision that is only ever wrong.
HTTPS_PROXY has no such history and is honoured.
Credentials
Userinfo in the URL becomes a Proxy-Authorization: Basic header:
http://user:password@proxy.example.com:3128. Percent-encode anything unusual —
DOMAIN%5Cuser is decoded before the header is built, since a backslash or an
@ in a proxy username is entirely ordinary and encoding it verbatim sends the
wrong credential.
On a tunnelled connection the credential is spent on the CONNECT and is
not repeated inside the tunnel, where the origin server would read it. The
configured URL is never logged with its password: every log line and every error
message renders it with the password replaced.
When the proxy is down
A configured but unreachable proxy is an error, every time. There is no fallback to a direct connection: dialling around a controlled egress path at exactly the moment the control fails is the opposite of what the setting is for. Errors name the proxy rather than the origin, so a refused connection points at the host that actually refused it.
[meta]
The optional meta members of the directory (§7.1.1). All are empty by default
and omitted from the directory when empty — never sent as an empty value.
terms_of_service (String) — Default: "" | Env: ACME_PROXY_META__TERMS_OF_SERVICE
URL of your terms of service. This one has teeth: setting it turns on
§7.3.3, so newAccount then refuses any request without termsOfServiceAgreed: true (403 userActionRequired + a Link: rel="terms-of-service" header), and
account objects begin reflecting termsOfServiceAgreed.
website (String) — Default: "" | Env: ACME_PROXY_META__WEBSITE
Informational URL about the ACME server. Advertised only.
caa_identities (Array) — Default: [] | Env: ACME_PROXY_META__CAA_IDENTITIES
Hostnames this CA recognizes in CAA records. Advertised only — this server performs no CAA checking.
[logging]
All six keys are validated at startup: an unknown value is a refusal to start with a message naming the key, never a silent fallback — a CA running at a log level or to a destination its operator did not ask for is the worse failure. The same validation runs on a reload, where a bad value refuses the whole thing rather than half-swapping the log stream.
All six also reload on SIGHUP, so raising the level
or switching to JSON mid-incident does not cost a restart.
What the resulting records actually contain, and what to alert on, is Monitoring & Observability.
filter (String) — Default: "acme_proxy=info" | Env: ACME_PROXY_LOGGING__FILTER
EnvFilter directive, and the last of three layers to be consulted. The
precedence, highest first:
--log-level, typed on the command line. A flag was asked for here and now, where the other two are ambient — the same reasoning that has--color alwaysoutrankNO_COLOR. It setsacme_proxyalone, so it never turns on a dependency’s logging by accident.RUST_LOG, when set to a non-empty value. It replaces the whole filter, so a bareRUST_LOG=debugalso turns on debug logging for every dependency.- this key.
That precedence is the same on a reload as at startup, which means editing this
key while either of the two outranks it changes nothing; the server logs
server_logging_filter_overridden, whose source field names which one, rather
than letting the edit pass for applied.
This section describes the server’s log stream. An admin command emits
nothing at all unless asked — see
the CLI’s --log-level.
json_format (Boolean) — Default: false | Env: ACME_PROXY_LOGGING__JSON_FORMAT
Emit JSON instead of the human-readable format. Set this in production if you ship logs to ELK, Loki, Datadog or similar: the structured fields become first-class keys rather than text to be re-parsed.
target (String) — Default: "stdout" | Env: ACME_PROXY_LOGGING__TARGET
Where records are written: stdout or stderr. Any other value is a startup
error.
ansi (Boolean) — Default: true | Env: ACME_PROXY_LOGGING__ANSI
ANSI colour in the human-readable format. Turn it off when the log is piped to a
file or a collector that does not strip escape sequences. Ignored when
json_format is on.
span_events (String) — Default: "none" | Env: ACME_PROXY_LOGGING__SPAN_EVENTS
Span lifecycle records: none, close or full. Any other value is a startup
error. close emits one record as each span ends, carrying the time spent busy
and idle inside it — the closest thing to per-operation timing available without
a metrics endpoint, and cheap enough to leave on. full adds
new/enter/exit and is a debugging tool.
flatten_event (Boolean) — Default: false | Env: ACME_PROXY_LOGGING__FLATTEN_EVENT
JSON only: lift a record’s own fields (event, and everything beside it) to the
top level instead of nesting them under fields. What most pipelines want; off
by default because a field can then collide with one of the format’s own keys.
[profiles.<name>]
An ACME endpoint is a profile. The server serves ACME only through them, and at least one enabled profile is required — startup fails otherwise, with a copy-pasteable minimal configuration in the error.
# The whole minimum. `enabled` defaults to true, so naming it is enough.
[profiles.default]
enabled (Boolean) — Default: true | Env: ACME_PROXY_PROFILES__<NAME>__ENABLED
Parks a profile without deleting its configuration. It also doubles as the one
key an environment-only profile needs:
ACME_PROXY_PROFILES__DEFAULT__ENABLED=true defines a working profile with no
configuration file at all.
Load-bearing rules:
- The mount path is derived from the name, never configured.
[profiles.le]answers at{base_url}/profile/le/directory. Names must match^[a-z0-9-]+$. The name is public API — it appears in everykidand order URL a client stores — so renaming a profile invalidates every client’s saved account. - Inheritance is per key, not per section. The global
[signer],[filter],[challenge],[eab],[order],[notify]and[meta]sections are the base each profile overlays. A profile that sets onlychallenge.bypasskeeps the globalchallenge.enabledrather than reverting it to the compiled default. Precedence: profile key > global key > compiled default. - Arrays replace wholesale, never append. A profile’s
filter.rulesfully replaces the global one — order is the policy, so it is stated once per profile rather than accumulated from two places. - Profiles are a database boundary, not just a URL prefix. Accounts and
orders carry a profile column, and accounts are keyed
UNIQUE(profile, pubkey)— one client key used at two endpoints is two independent ACME accounts. - Signer backends are shared by configuration. Two profiles with identical
[signer]sections share one backend instance. Two profiles sharing alocal_cakey path while differing elsewhere is a startup error.
[signer]
backend = "local_ca"
[filter]
rules = ["corp-only"]
[filter.check.corp-net]
type = "allowed_ip"
allow = ["10.0.0.0/8"]
[filter.rule.corp-only]
when = "corp-net"
then = "allow"
# Inherits everything above, but dry-runs the one rule: `[filter.rule.<name>]`
# is a table, so overriding `mode` keeps `when` and `then` from the global one.
[profiles.dev]
filter.rule.corp-only.mode = "warn"
# Overrides two keys; keeps the whole filter policy.
[profiles.prod]
signer.backend = "relay"
signer.relay.directory_url = "https://acme-v02.api.letsencrypt.org/directory"
See Profiles & Routing.
Environment variable gotchas
These bite in production and produce no error, so they are worth knowing before you configure anything through the environment.
Array-valued keys are parsed from a comma-separated string. That means a
value containing a literal comma cannot be expressed. A regex such as
^host\d{2,3}\.example\.com$ is therefore file-only — through the
environment, {2,3} splits into two list entries. In a file, write the array
form (deny = ["^host\d{2,3}\.example\.com$"]), which is taken exactly as
written; a bare string in a file is split on commas too, since by the time the
value is read there is nothing left to say where it came from.
Items are trimmed, and an empty item is a startup error. a, b is
["a", "b"], and a,,b or a trailing comma is refused by name rather than
silently dropped or kept as an entry that matches nothing.
An array set to the empty string is the empty list. Shell defaults like
ACME_PROXY_FILTER__RULES="${RULES:-}" set the variable to "", which the
configuration layer cannot distinguish from a deliberate value — so it is read
as “no values”, which is what clears a list the file set. Do not expect ""
to mean “fall back to the file”.
A numeric-looking value loses its leading zeros. The environment source
parses 007 as a number before the list is built, so it arrives as 7. A
value whose spelling matters — an argument to a custom signer, say — belongs
in the file’s array form.
Unknown keys are ignored, not rejected. A misspelled key, or a key written
under the wrong section, is silently dropped. The most common instance of this
is trusted_proxies written under [server] when it belongs to [filter] —
see
Allowed IP.
Sections documented elsewhere
The six sections with a chapter of their own, expanded — see the map above for the rest.
[signer]— Signers: local_ca and its PKCS#11 keys, relay, custom[filter]— Filters: allowed_ip, path, reverse_dns, identifiers, eab, custom[ipam]— IPAM: the inventory theipamcheck consults — NetBox, phpIPAM, a custom script[challenge]— Challenge Validation: http-01, dns-01, tls-alpn-01[notify]— Notifications: email, webhook, custom[eab]— External Account Binding