Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Built-in defaults — everything documented below has one, so an empty configuration is valid apart from [profiles].
  2. A configuration file — config.toml in the working directory, or the path in ACME_PROXY_CONFIG (the extension may be omitted, in which case the format is inferred). A missing file is not an error.
  3. ACME_PROXY_* environment variables — __ separates nested keys, a single _ separates the prefix. server.tls.enabled is ACME_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.

SectionControlsOverridableDocumented
[database]The SQLite file or PostgreSQL servernobelow
[server]Listen socket, public URL, admission controlnobelow
[server.tls]HTTPS on the ACME listenernobelow
[admin]The web admin listener and its sessionsnobelow
[admin.filter]Who may reach the admin listenernobelow
[admin.notify]Operator security notificationsnobelow
[admin.tls]HTTPS on the admin listenernobelow
[nonce]Replay-nonce freshnessnobelow
[audit]Reverse lookups and retention for the trailnobelow
[jobs]The durable background-work queuenobelow
[metrics]The Prometheus listenernobelow
[dns]The resolver every outbound lookup usesnobelow
[proxy]The forward proxy outbound clients dial throughnobelow
[logging]Filter, format, targetnobelow
[order]The ACME order object’s lifetimeyesbelow
[meta]Directory meta membersyesbelow
[profiles.<name>]An ACME endpoint—below
[signer]How a certificate is obtainedyesSigners
[filter]Who may ask, and for whatyesFilters
[ipam]The inventory an ipam filter check consultsyesIPAM
[challenge]How control of a name is provenyesChallenge Validation
[notify]Outbound notificationsyesNotifications
[eab]External Account BindingyesEAB

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.db is an absolute path (three slashes).
  • postgres:// or postgresql:// — 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. Run acme-proxy migrate once 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 /health is mounted outside all of them.

trusted_proxies and forwarded_header are 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_dns and custom checks are accepted. An identifiers, eab or ipam check, or a rule whose checks were moved to the identifier stage with stages, is refused by name at startup and on reload.
  • Empty rules filters nothing, and logs no warning.
  • trusted_proxies also keys the login limiter (see admin.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, and signer_relay_issue under the relay backend), 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, stale http-01 tokens, the daily CRL refresh (local_ca_crl_sweep), and this queue’s own retention_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:

keythenthen
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 always outrank NO_COLOR. It sets acme_proxy alone, 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 bare RUST_LOG=debug also 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 every kid and 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 only challenge.bypass keeps the global challenge.enabled rather than reverting it to the compiled default. Precedence: profile key > global key > compiled default.
  • Arrays replace wholesale, never append. A profile’s filter.rules fully 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 a local_ca key 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.