acme-proxy 0.2.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
# 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]`]#profilesname 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 table `acme-proxy` reads appears below, whether or not its text
lives here.

**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 | no | [below]#database |
| `[server]` | Listen socket, public URL, admission control | no | [below]#server |
| `[server.tls]` | HTTPS on the ACME listener | no | [below]#servertls |
| `[admin]` | The web admin listener and its sessions | no | [below]#admin |
| `[admin.tls]` | HTTPS on the admin listener | no | [below]#admintls |
| `[nonce]` | Replay-nonce freshness | no | [below]#nonce |
| `[audit]` | Reverse lookups and retention for the trail | no | [below]#audit |
| `[jobs]` | The durable background-work queue | no | [below]#jobs |
| `[metrics]` | The Prometheus listener | no | [below]#metrics |
| `[dns]` | The resolver every outbound lookup uses | no | [below]#dns |
| `[proxy]` | The forward proxy outbound clients dial through | no | [below]#proxy |
| `[logging]` | Filter, format, target | no | [below]#logging |
| `[order]` | The ACME order object's lifetime | **yes** | [below]#order |
| `[meta]` | Directory `meta` members | **yes** | [below]#meta |
| `[profiles.<name>]` | An ACME endpoint | — | [below]#profilesname |
| `[signer]` | How a certificate is obtained | **yes** | [Signers]../signers/index.md |
| `[filter]` | Who may ask, and for what | **yes** | [Filters]../filters/index.md |
| `[ipam]` | The inventory `filter.ipam` consults | **yes** | [IPAM]../ipam/index.md |
| `[challenge]` | How control of a name is proven | **yes** | [Challenge Validation]../challenges/index.md#reference |
| `[notify]` | Outbound notifications | **yes** | [Notifications]../notifications/index.md |
| `[eab]` | External Account Binding | **yes** | [EAB]../features/eab.md |

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. Controls the SQLite persistence layer for accounts,
orders, and certificates.

---

## `[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](../operations/reload.md).

**`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** `challenge.timeout_ms` and, when that
backend is installed, `signer.custom.timeout_ms` — both run inline inside a
request. The server refuses to start otherwise.

**`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]../filters/index.md#reference.

### `[server.tls]`

Full treatment in [TLS Termination](../features/tls_termination.md).

**`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](../operations/webadmin.md).

**`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, no filter chain and
— until `[admin.tls]` is on — 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:

```console
$ 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 peer address. **There is no forwarded-header handling on this
listener** — `filter.trusted_proxies` governs the ACME one, and trusting
`X-Forwarded-For` here without an equivalent allowlist would let any caller
spoof the key. Behind a reverse proxy the limiter 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](../operations/webadmin_users.md#second-factor-totp).

**`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 twenty 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](../operations/webadmin_templates.md).

### `[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](../challenges/index.md#reference) —
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.

---

## `[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](../operations/audit.md).

---

## `[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.

Four kinds of work run here, so this section's reach is wider than the name
suggests: relayed issuance under the `relay` signer backend, every
[notification](../notifications/index.md) delivery, and the four periodic table
sweeps (expired nonces, `audit.retention_days`, expired admin sessions, and this
queue's own `retention_days`). A runner that is not running is a server that is
not sweeping or notifying either — `job_runner_started` is the line that says it
is.

**`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`](#audit) 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]`](#servertls) and
[`[admin.tls]`](#admintls): 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](../operations/monitoring.md#metrics).

**`enabled`** (`Boolean`) — *Default: `false` | Env: `ACME_PROXY_METRICS__ENABLED`*

Bind the metrics listener and serve `GET /metrics`. Off by default, the same
posture [`[admin]`](#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`](#admin), 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](../operations/reload.md).

---

## `[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`](../operations/reload.md), 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](../operations/monitoring.md).

**`filter`** (`String`) — *Default: `"acme_proxy=info"` | Env: `ACME_PROXY_LOGGING__FILTER`*

`EnvFilter` directive, used **only when `RUST_LOG` is unset**. `RUST_LOG` wins
whenever it is present, and replaces the whole filter — a bare `RUST_LOG=debug`
therefore also turns on debug logging for every dependency. That precedence is
the same on a reload as at startup, which means editing this key while
`RUST_LOG` is set changes nothing; the server logs
`server_logging_filter_overridden` rather than letting the edit pass for
applied.

**`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.

```toml
# 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.enabled` fully
  replaces the global one.
- **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.

```toml
[signer]
backend = "local_ca"

[filter]
enabled = []

# Inherits everything above.
[profiles.dev]

# Overrides two keys; keeps the rest.
[profiles.prod]
signer.backend = "relay"
signer.relay.directory_url = "https://acme-v02.api.letsencrypt.org/directory"
filter.enabled = ["allowed_ip"]
filter.allowed_ip.allow = ["10.0.0.0/8"]
```

See [Profiles & Routing](../core/profiles.md).

---

## 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.

**An array set to the empty string is *present*, not absent.** Shell defaults
like `ACME_PROXY_FILTER__ENABLED="${FILTERS:-}"` set the variable to `""`, which
the configuration layer cannot distinguish from a deliberate value — and
`"".split(',')` yields one empty element, not zero. `acme-proxy` collapses this
back to an empty list for every array key, so it is safe; just do not expect
`""` to mean "fall back to the file".

**A list-valued key *inside* a profile needs its runtime key registered.** The
loader scans the environment for `ACME_PROXY_PROFILES__<NAME>__…` before
building its sources, which is what makes e.g.
`ACME_PROXY_PROFILES__LE__CHALLENGE__ENABLED` work. This is handled
automatically; it is documented here because it is the mechanism a new key can
accidentally miss.

**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](../filters/allowed_ip.md).

---

## Sections documented elsewhere

The five sections with a chapter of their own, expanded — see [the map
above](#every-section-and-where-it-is-documented) for the rest.

- **`[signer]`** — [Signers]../signers/index.md:
  [local_ca]../signers/local_ca.md and its
  [PKCS#11 keys]../signers/local_ca_hsm.md,
[relay]../signers/relay.md, [custom]../signers/custom.md
- **`[filter]`** — [Filters]../filters/index.md:
  [allowed_ip]../filters/allowed_ip.md,
  [reverse_dns]../filters/reverse_dns.md,
[identifiers]../filters/identifiers.md, [ipam]../ipam/index.md,
  [custom]../filters/custom.md
- **`[challenge]`** — [Challenge Validation]../challenges/index.md#reference:
  [http-01]../challenges/http_01.md#reference,
  [dns-01]../challenges/dns_01.md,
  [tls-alpn-01]../challenges/tls_alpn_01.md#reference
- **`[notify]`** — [Notifications]../notifications/index.md:
  [email]../notifications/email.md,
  [webhook]../notifications/webhook.md,
  [custom]../notifications/custom.md
- **`[eab]`** — [External Account Binding]../features/eab.md