Admin CLI
acme-proxy embeds an administrative command-line interface in the same binary,
so a full deployment never needs a separate tool to manage its state: accounts,
orders, the audit trail, the job queue, nonces, EAB credentials, the upstream
account, the web admin’s operators and sessions, and the database itself
(migrate, init, transfer). profile and filter read the configuration
back as the server would build it.
Invoking it
serve is the default subcommand, so a bare acme-proxy starts the server.
You reach the admin CLI by naming a subcommand:
acme-proxy account list
Every command reads the same configuration as the server — config.toml in the
working directory, or ACME_PROXY_CONFIG, plus ACME_PROXY_* environment
overrides — so it must be run where the configuration points at the same
database. There is no --config flag.
Commands operate directly on the database, SQLite or PostgreSQL. Running them against a live server is safe (SQLite runs in WAL mode; PostgreSQL is built for concurrent writers), but they act immediately and are not transactional across the server’s own in-flight requests.
The schema is applied explicitly
Opening the database does not migrate it. acme-proxy migrate applies any
migrations that have not run yet, and a serve running the
worker role does the same at startup — so a default single-process acme-proxy serve against a fresh database still just works.
Everything else checks the schema and refuses by name:
database error: the schema is 3 migration(s) behind; run `acme-proxy migrate` first
This used to be a side effect of opening the database, which made every
subcommand an upgrade step — acme-proxy audit list from a newer binary
silently rewrote the schema — and let two processes starting together race the
migration runner, SQLite offering sqlx no lock to serialise them.
acme-proxy migrate is idempotent and safe to run repeatedly; it prints how
many migrations it applied, or says the schema is already up to
date.
acme-proxy init migrates and then generates whatever first-run material
the configuration calls for — the local CA key and certificate, an upstream
account for a relay profile, a self-signed TLS certificate. It is the one
command that creates key material, so a split deployment runs it once, as the
uid that should own those files, before starting anything.
Moving between backends
acme-proxy transfer --to <url> copies every row of the configured
database into another one. The scheme of each URL picks its backend, so this is
how a SQLite deployment becomes a PostgreSQL one — and the reverse is the same
command with the two swapped.
$ acme-proxy transfer --to postgres://acme@db.internal/acme
Copy 14203 row(s) from sqlite://acme.db to postgres://acme:***@db.internal/acme?
The source server must be stopped, or the copy is a torn snapshot.
Continue? [y/N] y
accounts 412
orders 9881
audit_log 3910
…
Copied 14203 row(s) into 15 table(s).
Stop the server first. Nothing can check it: a worker that issues a certificate while the copy is running writes rows the copy has already walked past, and the result looks exactly like a good one. That is the only part of this an operator has to get right unaided.
The target must already exist, be migrated and be empty. Create the
database, run acme-proxy migrate against it (this command will not — applying
a schema belongs to migrate, init and the worker role, and nothing else),
then transfer. A target that already holds rows is refused by name, listing
them: a transfer is a copy, not a merge, and there is no flag that makes it
one.
Row ids, certificate serials and audit ids all survive, because all three are
things something outside the database still refers to — a kid a client
stored, a serial the CRL carries, an id an operator typed. A certificate
issued before the move is revocable after it.
What does not travel is the schema’s own history: each backend keeps its own
migration set and checksums. And --json answers
{"tables": [{"table", "rows"}], "total"} — a report of a copy, not a listing,
so it has its own shape rather than the paged envelope.
Roles
serve takes --role, a comma-separated list of acme, admin and
worker. With no --role it runs all three in one process, which is the
default and what every deployment before the flag existed did.
| Role | Does |
|---|---|
acme | Serves ACME to certificate clients: the ACME listener and the root router. Enqueues work, runs none. |
admin | Serves the web admin, /ui and /api. Enqueues work, runs none. |
worker | Drains the job queue, and owns the schema and the first-run material. |
Splitting them puts the process that parses untrusted JWS and CSRs, the process that holds operator sessions, and the process that reaches out to client-chosen hosts in three different places, each able to run under its own uid. See Deployment.
An unknown role is refused with usage before anything is read:
error: invalid value 'wroker' for '--role <ROLES>': unknown role `wroker`
(expected one of: acme, admin, worker)
A process running no worker logs the advisory server_role_no_worker at
startup: nothing there drains the queue, so challenge validation, notifications
and the periodic sweeps all wait for a process that does.
Global flags
-y, --yes — skip the interactive “Are you sure?” prompt on destructive
commands. It is a global flag, so it may be given anywhere on the line. account delete, order delete, eab delete, jobs cancel, audit cleanup, nonce cleanup, transfer, admin user delete and admin user totp reset prompt;
nothing else is gated by it.
--json — where supported, emit JSON instead of the human-readable line
format. Single-item commands print one JSON object. Every list command prints
the same envelope the admin JSON API returns, so a script does not learn one
shape for the shell and another for the API:
{ "items": [ … ], "total": 137, "limit": 50, "offset": 0 }
total is what the same filters match unpaged, which is the difference
between having read the table and having read a page of it. See
Paging. (It is not newline-delimited JSON.)
--color <auto|always|never> — when to colour the human-readable output.
Also global. The default is auto: colour when the stream is a terminal and
NO_COLOR is unset or empty, which means a piped or redirected run is plain
without your having to say so.
alwayscolours regardless of the stream and regardless ofNO_COLOR— it was typed on this command line, so it outranks both. That is what makesacme-proxy audit list --color always | less -Rwork.nevernever colours, whatever the terminal is.- An unrecognised value is refused rather than treated as
auto.
Colour is decided separately for stdout (the output) and stderr (error
messages), since the two are redirected independently. It is semantic, never
decorative: statuses (valid, pending, revoked…), audit events that name
a refusal, filter explain’s per-check verdicts, and the standing warnings such
as eab create’s “shown only this once”. Labels, timestamps and identifiers are
never coloured.
--json output never carries colour, at any setting — it is the same bytes
a script parses today. So is every human-readable line under --color never.
Note this is not the same switch as logging.ansi, which colours the server’s
log stream and is a configuration key rather than a flag; the CLI’s colour is
not configurable, on purpose, since the right answer depends on the terminal in
front of you rather than on the deployment.
--log-level <off|error|warn|info|debug|trace> — emit log records for this
run. Also global.
An admin command prints only its own output by default: no log records at
all, on either stream. That is what makes acme-proxy account list --json | jq
work — before this flag existed, a db_migration_completed record landed on
stdout ahead of the JSON on every invocation, because [logging] was installed
for every subcommand and its target defaults to stdout.
- Records go to stderr, whatever
logging.targetsays. stdout is the answer; a diagnostic does not belong in it. - The level covers
acme-proxyalone, so--log-level debugdoes not also turn onsqlxandhyper. SetRUST_LOGfor a directive that reaches further — a non-emptyRUST_LOGturns records on by itself, with no flag. --log-level offis silence stated explicitly, which is what a script wants when the environment it runs in may carry aRUST_LOG.
It is worth reaching for on filter show, which builds the policy exactly as
startup does and so is the cheapest pre-restart check: the refusals are
printed either way, but the advisories are log records, so
filter show --log-level warn is how you see filter_disabled and
filter_check_unused before a restart rather than after it.
On serve the flag outranks both RUST_LOG and logging.filter — a flag was
typed where the other two are ambient — and [logging]’s other five keys still
decide the format, the target and the rest. It survives a SIGHUP, so an
operator who started the server at --log-level debug keeps it across a reload;
an edit to logging.filter is then a no-op the server warns about
(server_logging_filter_overridden).
--version — print the build’s own version and exit. It is the first thing
a bug report asks for, and the answer a checkout cannot give on a host where the
binary was copied in. --help is its counterpart and works at every level:
acme-proxy audit --help lists that group’s subcommands.
Exit codes
An admin command exits with one of these. A script can branch on the code
without parsing stderr; the deciding question between 1 and 3 is whether
re-running the identical command is worth it.
| Code | Meaning | Examples |
|---|---|---|
0 | Success — the command did what was asked. | |
1 | The host could not carry out the request. Worth retrying, or fixing the host and retrying. | A database that will not open, a signer or CA error, an unreadable --password-file, an unreachable upstream, a broken [dns]/[proxy] section, invalid configuration. |
2 | The command line itself was rejected. Emitted by the argument parser. | An unknown flag or subcommand, a missing argument. |
3 | The request cannot be satisfied as written. Re-running the identical command will not help. | No object with that id (no such order …); an object in the wrong state (… is already revoked, only ready or failed jobs can be cancelled); an unknown --status/--event/--outcome value, or --role on admin user create; contradictory flags (--hide-superseded without --expiring-in); nothing supplied on stdin where a password or an EAB key was asked for. |
serve exits 1 for any startup failure and otherwise runs until it is
signalled.
Shell completions
acme-proxy completions <shell> prints a completion script on stdout, for
bash, elvish, fish, powershell or zsh. It is generated from the same
command tree clap parses, so it covers every subcommand and flag, four levels
deep — acme-proxy admin user totp completes to status, reset and
recovery-codes. Flag values complete only where the flag has a fixed set
clap knows about, which today is --color, --log-level and completions’
own <shell>; --status and --outcome take a
string the command refuses by name, so a shell has nothing to offer for them.
The command reads neither the configuration nor the database, so it works anywhere, including in a shell startup file and before a deployment exists.
# bash — system-wide, or ~/.local/share/bash-completion/completions/acme-proxy
acme-proxy completions bash | sudo tee /etc/bash_completion.d/acme-proxy
# zsh — any directory on $fpath; the file must be named _acme-proxy
acme-proxy completions zsh > ~/.zfunc/_acme-proxy
# fish
acme-proxy completions fish > ~/.config/fish/completions/acme-proxy.fish
Regenerate after upgrading: before 1.0.0 the CLI is not frozen, so a script kept from an older binary can go on offering a subcommand that no longer exists. The policy is stated in the changelog.
One limitation is the generator’s rather than this CLI’s: the fish script
stops completing at three levels, so acme-proxy admin user totp offers
nothing past totp. The other four shells complete the whole tree.
Man page
acme-proxy man prints the roff source of acme-proxy.1 on stdout. Like
completions, it reads nothing and is generated from the command tree:
acme-proxy man | sudo tee /usr/share/man/man1/acme-proxy.1 > /dev/null
man acme-proxy
It is one page for the top-level command — the options, every subcommand with
its one-line purpose, the environment variables, the configuration file, and a
pointer back to this book. The per-flag detail of each subcommand lives in the
tables below rather than in the page, which is why SEE ALSO names the book.
Read it without installing anything with acme-proxy man | man -l -.
Account management
| Command | Flags |
|---|---|
account list | --profile <name>, --eab-kid <kid>, --limit <n>, --offset <n>, --json |
account show <id> | --json |
account update-contact <id> | --contact <uri> (repeatable) |
account deactivate <id> | — |
account delete <id> | (prompts) |
account list --profilerestricts the listing to one ACME endpoint. Without it, accounts from every profile are listed — the admin CLI is deliberately unscoped by default, unlike the request path, which always scopes by profile. The listing is newest first and paged; see Paging below.account list --eab-kidlists the accounts one EAB credential registered — what to look at beforeeab delete.account listshows, per account, the address its key was last seen from and that address’s reverse name (ip (ptr), the address alone when no name resolved,-when neither was recorded).account showprints one field per line and adds where the account was registered from. Nothing in the server ever compares against these — pinning an identity to an address breaks CGNAT and mobile — and none of them reaches an ACME object.account deactivateprevents the account from making any further requests. It is the operator-side equivalent of a client deactivating itself.account deletecascades: every order, authorization and challenge belonging to the account is destroyed with it. The prompt names what will go.account deleteandorder deleteare refused while a live certificate would go with them — one issued, not revoked, and not known to have expired. An order row is the only record of its certificate: without it,revokeCertandorder revokecannot find the certificate, and it drops out of the expiry digest and renewal information. Revoke it first (order revoke), or wait for it to expire. A certificate whose expiry was never recorded counts as live. There is no flag to override this.
Order management
| Command | Flags |
|---|---|
order list | --profile <name>, --account-id <id>, --status <status>, --identifier <name>, --identifier-contains <text>, --cert-serial <hex>, --expiring-in <days>, --hide-superseded, --limit <n>, --offset <n>, --json |
order show <id> | --json |
order chain <id> | — |
order delete <id> | (prompts) |
order revoke <id> | --reason <n>, --wait <seconds> (default 30) |
-
--statusis one ofpending,ready,processing,validandinvalid, and is refused by name otherwise. -
--identifier <name>finds the orders that name that identifier exactly (case-insensitive): the answer to “which order coversweb.corp.example.com”. It is an exact match on purpose —--identifier example.comwill not surfaceevil-example.com, which is the wrong thing to hand somebody hunting a misissuance.--identifier-contains <text>is the substring form for when only a fragment of the name is remembered; the two are mutually exclusive.A wildcard order stores the wildcard form, so
--identifiermatches*.example.comand nothost.example.com— “which order named this?” is not “which certificate covers this?”, and only the first is a question an exact match can answer.--identifier-contains example.comspans both. -
--cert-serial <hex>finds the order whose issued certificate carries that serial — the value an abuse report hands you, and the same oneaudit list --cert-serialfilters on. Case and separators do not matter: whatopenssl x509 -serialprints (upper case) and what a report quotes (often colon-separated) are both folded to the form the column holds. -
order list --expiring-in <days>asks a different question over a different query: the certificates this CA issued that reach their notAfter inside the window, soonest first, each annotated with whatever has already replaced it. It is the same listing the[notify.expiry]digest mails and the panel shows at/ui/expiring, so the three cannot come to disagree about what “expiring” or “already replaced” means. Add--hide-supersededto drop the rows that have a successor and leave only the ones to act on. Under--jsonthe envelope carries two more members, asGET /api/expiringdoes:hidden, the rows--hide-supersededdropped from this page, anddays, the window asked for. -
--status,--account-id,--identifier,--identifier-containsand--cert-serialare refused with--expiring-in, by name. The expiry listing is issued, unrevoked certificates by definition, has no account predicate and is ordered by expiry, so any of them would silently mean something other than it does elsewhere — the rule--statusandaudit list --eventalready follow. -
order showprints one field per line, omitting every field that was not recorded rather than rendering it empty — the shapeaudit showandaccount showhave. It covers the certificate’s serial and the leaf’s ownnotAfterbeside the requestednotAfterthe client asked for, and the revocation timestamp and reason, which are deliberately absent from the ACME JSON a client sees — revocation state is admin-visible only. -
order showandorder show --jsondescribe the same order, field for field, with one deliberate exception: the issued chain.--jsoncarries it ascertificatePemand the web panel offers it as a download; the text rendering does not, a command run to get one’s bearings being the wrong place for several kilobytes of PEM. The three URL members (authorizations,finalizeand the ACMEcertificateURL, which is reachable only by signed POST-as-GET) are likewise--json’s alone — the indented authorization tree is what a terminal reads instead. -
order chain <id>prints that chain and nothing else, so it pipes:$ acme-proxy order chain 0198f3b1-... > web.example.com.pemIt is the terminal’s spelling of the panel’s
GET /ui/orders/{id}/chain.pemdownload, and it keeps that route’s rule: an order that never reached issuance is an error, not an empty file, because zero bytes named.pemread as a broken certificate rather than an absent one. There is no--json— the PEM is the output. -
order revokeis the operator-side equivalent ofPOST /revokeCert, for an out-of-band compromise report a client cannot or will not act on. It never loads a CA key or contacts an upstream itself; the running server does the signing, as Revocation & CRL describes. It is not confirm-gated, because revocation only ever tightens trust.--reasonis the RFC 5280 reason code:0–6or8–10, since7is unused; any other value is refused. Omitted, the revocation carries no reason. For arelayorcustomprofile the revocation is queued for the running server, and--waitis how many seconds the command waits for it to land before returning (default 30);--wait 0queues it and returns at once. A local CA’s revocation is recorded immediately and does not wait.
Job queue
The background queue (relayed issuance, notification delivery, the periodic
sweeps) is what keeps an order processing through a transient upstream failure
instead of failing it. When an order is stuck, this is where to look.
| Command | Flags |
|---|---|
jobs list | --kind <k>, --status <s>, --limit <n>, --offset <n>, --json |
jobs show <id> | --json |
jobs cancel <id> | (prompts) |
jobs run-now <id> | — |
-
jobs listis paged like the other listings, newest first.--statusis one ofready,running,done,failed,cancelledand is refused by name — passed to SQL an unknown value answers “no rows”, which reads as “nothing is in that state”.--kindis not refused: a job kind is an open set, so a typo simply matches nothing. -
jobs showprints one field per line, and for a relay issuance job (kind = signer_relay_issue) it appends the upstream order it drives — the upstream URLs and the upstream’s own error text. The stored CSR is never shown. -
jobs cancelretires a job (status = cancelled) and is confirm-gated. Two things it will not do:- A
runningjob is refused — a runner owns it, and its lease will expire or it will settle. Wait it out, then cancel the resultingready/failedrow. - Cancelling a periodic sweep job (
nonce_sweep,audit_sweep,order_sweep, …) stops that sweep until the server restarts. The prompt says so.
Cancelling an in-flight
signer_relay_issuejob also abandons the ACME order: the local order is markedinvalid(so the client stops polling), the upstream mapping is abandoned (so a restart does not resume it), and acertificate_issue_failedaudit row is written naming you. This is the operator-side way to stop a relayed issuance that will never complete. - A
-
jobs run-nowmakes a job eligible immediately — it is picked up withinjobs.poll_interval_ms(it does not wake the runner). On areadyjob it just pullsrun_atforward; on afailedjob it grants exactly one more attempt (attemptsis set tomax_attempts - 1), not a fresh budget, because a full reset is what turns a permanently failing job into an infinite retry loop. It is not confirm-gated.
The web admin has the same surface at /ui/jobs (GET /api/jobs), with cancel
and run-now behind an operator-or-higher session.
Audit trail
| Command | Flags |
|---|---|
audit list | --profile <name>, --account-id <id>, --order-id <id>, --cert-serial <hex>, --event <e>, --outcome success|failure, --since-days <n>, --limit <n>, --offset <n>, --json |
audit show <id> | --json |
audit cleanup | --older-than <days> (prompts) |
audit listis paged like every listing; see Paging.- An unknown
--eventor--outcomeis refused by name, listing the values this build knows. Passed through to SQL it would answer “no rows”, which reads exactly like “nothing happened”. audit showprints one field per line, omitting every field that was not recorded rather than rendering it empty.audit cleanupis the only command in this binary that destroys audit history, so it is confirm-gated and its prompt names the row count.audit.retention_daysruns the same sweep daily.
The web admin can read this trail but not prune it — see Audit Trail.
Paging
Every listing in this binary is paged. account list, order list (both
of its queries), audit list, jobs list, eab list, upstream order list,
admin user list and admin session list all take --limit <n> and
--offset <n>, defaulting to 50 rows. orders and audit_log each grow a
row per issuance for the life of the deployment, so there is deliberately no
“everything” spelling and --limit 0 is not a way around it: on a year-old CA
that is a terminal full of scrollback and a table loaded into memory. A
nonsense window is corrected rather than refused — a --limit 0 becomes one
row, a negative --offset becomes zero.
eab list, admin user list and admin session list used to answer a bare
JSON array with no window, on the argument
that an operator mints those rows by hand a few at a time. That was true of how
the tables fill and said nothing about how long they have been filling — and it
made a script learn one shape for the shell and another for /api.
Every paged listing ends with a count, always and not only when the page is short:
$ acme-proxy order list --limit 2
...
2 of 1877 row(s).
“42 of 1877” is the difference between having read the table and having read a
page of it. Page with --offset; the listings are ordered newest first,
tie-broken on the row id, so a row cannot swap between pages and go unseen.
admin user list is the one exception, and is oldest first. The bootstrap
operator — created before there was a panel to sign in to — is precisely the
row whose position should not move as colleagues are added.
order list --expiring-in adds a third number when --hide-superseded drops
rows, because supersession is decided per row and cannot become part of the
query — so the total counts the window, not the rows printed under it:
$ acme-proxy order list --expiring-in 30 --hide-superseded --limit 20
...
6 of 8 row(s), 2 superseded hidden.
Under --json every one of them answers the same envelope the admin API
returns, member for member (order list --expiring-in adds hidden and days,
as its API twin does):
$ acme-proxy eab list --limit 2 --json
{"items":[…],"total":37,"limit":2,"offset":0}
The window is not clamped to admin.page_size_max. That key is a ceiling on
what an HTTP caller may ask the server for; this front end already answers to a
shell on the host.
Access policy
| Command | Flags |
|---|---|
filter show | --profile <name>, --json |
filter explain | --profile <name>, --client-ip <ip>, --identifier <name> (repeatable), --path <p> (default /newOrder), --account-id <id> (default explain), --json |
--profile may be omitted only when exactly one profile exists, the same rule
upstream show follows: [filter] is per-profile, so acting on “the policy”
without saying which one would be acting on nothing.
filter show prints the resolved policy — every check with its type and the
stages it decides at, then every rule in evaluation order with its condition
re-parenthesized. That last part is the point: an operator who wrote
a or b and c sees a or (b and c) printed back and has their answer about
precedence without reading the grammar.
Both commands build the policy rather than reading the file back, so every
startup refusal reaches you here too. filter show is therefore the cheapest
way to check a policy before restarting the server. Like every command but
completions and man, they open the configured database first, so on a fresh
host run acme-proxy migrate before them:
$ acme-proxy filter show
profile: default
default: deny (when a rule was applicable and none matched)
checks
inventory ipam identifiers only
mgmt-net allowed_ip connection and identifiers
rules (first match wins)
mgmt-bypass mgmt-net -> allow
evaluated at: connection and identifiers
inventory-owned inventory or mgmt-net -> allow
evaluated at: identifiers only
--json prints the same policy as a document, which is what a configuration
check in CI reads — and is the shape the web panel renders, so the two front
ends cannot come to describe one policy differently:
{
"profile": "le",
"active": true,
"defaultEffect": "deny",
"warning": null,
"checks": [
{ "name": "mgmt-net", "type": "allowed_ip", "stages": "connection and identifiers" }
],
"rules": [
{ "name": "inventory-owned", "when": "corp-names and (inventory or mgmt-net)",
"then": "allow", "mode": "enforce", "stages": "identifiers only" }
]
}
checks is name-sorted and rules is evaluation order. An endpoint with no
rules answers "active": false and the warning above, with defaultEffect
null rather than the configured word: filter.default is consulted only
where some rule was applicable, so with no rules it is not a fact about that
endpoint at all.
filter explain evaluates it against a hypothetical request and reports all
three stages — connection, newOrder and CSR — because every stage must
allow, and that is the thing most easily misread. For each it prints every
check’s verdict with its reason, which rule matched, and the HTTP answer that
stage would produce.
$ acme-proxy filter explain --client-ip 10.0.0.5 --identifier web.corp.example.com
Checks the evaluation never reached are listed as skipped: a short-circuited operand and a passing one look identical in the outcome, so this is the only way the output can answer “why did my inventory check not run”.
This really runs the policy.
filter explainexecutes yourcustomscripts and issues real IPAM and DNS requests, exactly as a request would, because a stubbed answer would be worse than nothing the first time it disagreed with production. It writes nothing, and it names the checks that reached outside the process at the end of its output (sideEffectsunder--json).That is also why
explainis a host-only command with no web-admin equivalent: the address and names are chosen by the caller, so behind a session it would be script execution and outbound requests driven from one stolen cookie.showis the opposite case and the panel does serve it — see Web Admin — because it reads an already-built policy and reaches nothing outside the process.
Nonce housekeeping
| Command | Flags |
|---|---|
nonce count | --json |
nonce cleanup | --ttl-seconds <n> (prompts) |
nonce cleanup deletes expired nonces. The server already sweeps them on an
interval for the life of the process, so this is mainly a debugging tool.
--ttl-seconds defaults to the configured nonce.ttl_seconds.
nonce count reports the table size and the window a nonce is fresh for — the
terminal’s spelling of GET /api/nonces, answering the same
{count, ttlSeconds} under --json:
$ acme-proxy nonce count
1284 nonce(s), ttl 300s.
The two numbers are only meaningful together. The count should sit near the request rate times the TTL; one far above that says the reaper is not running. Values are never listed, on either surface: a nonce is a bearer credential until it is consumed, so a listing would put live ones on a screen.
Profiles
| Command | Flags |
|---|---|
profile list | --json |
The ACME endpoints this configuration mounts, name-sorted, each with the two facts that decide whether it is safe to expose and then its directory URL — last, because it is the only field here with no bounded width, and any column after it would be ragged:
$ acme-proxy profile list
internal challenges=bypassed eab=off https://ca.example.com/profile/internal/directory
le challenges=validated eab=on https://ca.example.com/profile/le/directory
challenges=bypassed is painted as a warning, because it is one: an endpoint
that marks a challenge valid without checking anything is an open CA wherever
[filter] is empty. A profile parked with enabled = false is absent, which is
the honest answer to “what does this mount”.
Like filter show, this builds the answer the way startup does rather than
reading a file back, so a configuration serve would refuse is refused here
too — which makes it a pre-restart check as well as a listing. The panel’s
GET /api/profiles and /ui/profiles render the identical document from the
mounted profiles instead, so between an edit and its SIGHUP the two
legitimately disagree, and only this one can be pointed at a configuration the
server would not start on.
Upstream account management
Only relevant with signer.backend = "relay".
| Command | Flags |
|---|---|
upstream show | --profile <name>, --json |
upstream register | --profile <name>, --eab-kid <kid>, --eab-hmac-key-file <path> |
upstream order list | --profile <name>, --status <s>, --limit <n>, --offset <n>, --json |
upstream order show <local-order-id> | --json |
--profile is required for show/register whenever the configuration
defines more than one profile. [signer] is a per-profile section, so acting
on “the upstream” without saying which one would be acting on nothing. It may be
omitted only when exactly one profile exists. upstream order list takes
--profile as an ordinary filter and is cross-profile without it.
upstream register performs this proxy’s own newAccount at the upstream CA
and stores the resulting account URL beside account_key_path with a .kid
extension. It is the only time the account is registered: serve reads the
stored kid and never needs the EAB credential again.
Security note: the EAB HMAC secret is read from
--eab-hmac-key-file, or prompted on stdin. It is deliberately not accepted as a command-line argument, because argv is visible to every user on the host viaps. Omit--eab-kidentirely when the upstream requires no External Account Binding.
upstream order list|show reads the upstream_orders table — one row per local
order this proxy relayed to its upstream. --status is processing, valid
or invalid, refused by name. Each row carries the upstream order/finalize/
certificate URLs, the upstream CA’s own error text for a failed relay, and
the finalize request’s request_id; show takes the local order id and
cross-links to the relay job. It is read-only — to stop an in-flight relay, use
jobs cancel on the signer_relay_issue job (see Job queue),
which abandons the local order too. The panel twins are
GET /api/upstream-orders and /ui/upstream-orders.
External Account Binding (EAB)
| Command | Flags |
|---|---|
eab create | --label <text>, --profile <name>, --json |
eab list | --limit <n>, --offset <n>, --json |
eab show <kid> | --json |
eab revoke <kid> | — |
eab delete <kid> | --deactivate-accounts or --delete-accounts (prompts) |
eab createprints the generated HMAC secret once. It is stored but never shown again, so a lost secret is replaced, not recovered.--profilebinds the credential to one endpoint. Omitted, the credential is accepted at every profile — which is what an unscoped credential means, and is usually not what you want in a multi-tenant deployment.eab revoketakes effect immediately, with no restart: credentials are read from the live database on everynewAccount.eab deleteremoves the credential. By default its accounts are kept, but they no longer resolve to any credential, so everyeabfilter check refuses them.--deactivate-accountsalso deactivates them and keeps their orders, so their certificates can still be revoked.--delete-accountsalso deletes them and everything under them; likeaccount delete, it is refused while any of their orders holds a live certificate, and then nothing changes. The prompt names how many accounts and orders are involved.eab listis newest first and paged; see Paging. It reads the same queryGET /api/eaband/ui/eabdo, so the three cannot come to describe the credential set differently.
See External Account Binding for the protocol side.
Web admin operators and sessions
The web admin has no sign-up page: the first operator is created here. These
commands work whether or not [admin] is enabled, and whether or not the server
is running.
| Command | Flags |
|---|---|
admin user create <username> | --password-file <path>, --role admin|operator|viewer (default admin), --contact <address> |
admin user list | --limit <n>, --offset <n>, --json |
admin user show <username> | --json |
admin user passwd <username> | --password-file <path> |
admin user role <username> <admin|operator|viewer> | revokes the operator’s sessions |
admin user contact <username> | --contact <address> (omit, or pass empty, to clear) |
admin user delete <username> | confirm-gated; -y skips |
admin user disable|enable <username> | — |
admin user totp status <username> | --json |
admin user totp reset <username> | confirm-gated; -y skips |
admin user totp recovery-codes <username> | prints them once |
admin session list | --user <u>, --limit <n>, --offset <n>, --json |
admin session revoke | --user <u> (optionally --session <id>) or --all |
$ printf '%s' "$PASSWORD" | acme-proxy admin user create alice
Created admin user alice (bac6a47e-711b-4e8e-858e-417da905dab9), role admin.
- The password never goes in argv. There is no
--passwordflag andclaprejects one: argv is visible viapsand lands in shell history. Supply it on stdin or with--password-file(which strips one trailing newline). Typing it interactively works but echoes, and the command says so. - Minimum 12 characters. Stored as PBKDF2-HMAC-SHA256 at 600 000 iterations, and
not recoverable — a lost password is replaced with
admin user passwd. admin user passwdandadmin user disableboth revoke every session that user holds. A password changed because it may have leaked, that left the leaked session alive, would be a change in name only.--role/admin user roleset the operator’s privilege tier, which scopes what their web sessions may do —viewerreads only,operatoradds every CA action,adminadds managing other operators. It does not restrict the CLI: the host is the trusted plane. An unknown value is refused by name, andadmin user rolerevokes the operator’s sessions so a demotion takes effect at once. A row that predates the feature reads asadmin. See Web Admin — Roles.- Usernames are stored lowercased, so
Aliceandalicecannot become two logins that read as one in a log line. - There is deliberately no
admin user totp enrol. Enrolling happens in the panel, which shows the setup key once behindCache-Control: no-store; there is no way to do it from a terminal that does not put that key into scrollback and shell history — the same reasoning that keeps a password out ofargv. What the shell is for is the case the panel cannot serve:totp resetis how an operator who has lost their authenticator gets back in. It asks first, because it removes a security control rather than tightening one, and it takes the recovery codes and every live session with it. admin user showis the detail beside the listing, and adds the two things a row cannot carry: whether enrolment was started and never confirmed, and how many recovery codes are left. That first one matters because “enrolment pending” and “no factor” behave identically at the login prompt — an operator who believes they enrolled has no other way to find out.admin user totp statussays the same thing about the factor alone. It also shows the operator’scontactaddress and the recent login addresses that raise a “new address” notification.--contact/admin user contactset the address a web-admin operator receives security notifications at (a sign-in from an unfamiliar address, a refused second factor, a lockout, a credential change — see Web Admin — Security notifications). The address is validated as a mailbox; a bad one is refused.admin user contactwith no--contact, or an empty one, clears it. Notifications are delivered only whenadmin.enabledand[admin.notify]are configured. A change made from this CLI — a contact address,passwd,totp reset,recovery-codes— is notified like one made in the panel: the CLI queues the message and the running server’s worker sends it.admin session listshows a fingerprint of the stored token hash, never the hash itself. That fingerprint is the<id>admin session revoke --user <u> --session <id>takes to end one session rather than all of an operator’s — the granularity the panel’s Operators and Account pages already have.--sessionneeds--user, since the fingerprint only names a row within one operator’s sessions. Both listings are paged; see Paging, which also has the reasonadmin user listis the one listing ordered oldest first.
See Web Admin — Users & Sessions for the full treatment.