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

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.

RoleDoes
acmeServes ACME to certificate clients: the ACME listener and the root router. Enqueues work, runs none.
adminServes the web admin, /ui and /api. Enqueues work, runs none.
workerDrains 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.

  • always colours regardless of the stream and regardless of NO_COLOR — it was typed on this command line, so it outranks both. That is what makes acme-proxy audit list --color always | less -R work.
  • never never 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.target says. stdout is the answer; a diagnostic does not belong in it.
  • The level covers acme-proxy alone, so --log-level debug does not also turn on sqlx and hyper. Set RUST_LOG for a directive that reaches further — a non-empty RUST_LOG turns records on by itself, with no flag.
  • --log-level off is silence stated explicitly, which is what a script wants when the environment it runs in may carry a RUST_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.

CodeMeaningExamples
0Success — the command did what was asked.
1The 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.
2The command line itself was rejected. Emitted by the argument parser.An unknown flag or subcommand, a missing argument.
3The 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

CommandFlags
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 --profile restricts 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-kid lists the accounts one EAB credential registered — what to look at before eab delete.
  • account list shows, 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 show prints 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 deactivate prevents the account from making any further requests. It is the operator-side equivalent of a client deactivating itself.
  • account delete cascades: every order, authorization and challenge belonging to the account is destroyed with it. The prompt names what will go.
  • account delete and order delete are 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, revokeCert and order revoke cannot 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

CommandFlags
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)
  • --status is one of pending, ready, processing, valid and invalid, and is refused by name otherwise.

  • --identifier <name> finds the orders that name that identifier exactly (case-insensitive): the answer to “which order covers web.corp.example.com”. It is an exact match on purpose — --identifier example.com will not surface evil-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 --identifier matches *.example.com and not host.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.com spans both.

  • --cert-serial <hex> finds the order whose issued certificate carries that serial — the value an abuse report hands you, and the same one audit list --cert-serial filters on. Case and separators do not matter: what openssl x509 -serial prints (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-superseded to drop the rows that have a successor and leave only the ones to act on. Under --json the envelope carries two more members, as GET /api/expiring does: hidden, the rows --hide-superseded dropped from this page, and days, the window asked for.

  • --status, --account-id, --identifier, --identifier-contains and --cert-serial are 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 --status and audit list --event already follow.

  • order show prints one field per line, omitting every field that was not recorded rather than rendering it empty — the shape audit show and account show have. It covers the certificate’s serial and the leaf’s own notAfter beside the requested notAfter the 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 show and order show --json describe the same order, field for field, with one deliberate exception: the issued chain. --json carries it as certificatePem and 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, finalize and the ACME certificate URL, 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.pem
    

    It is the terminal’s spelling of the panel’s GET /ui/orders/{id}/chain.pem download, and it keeps that route’s rule: an order that never reached issuance is an error, not an empty file, because zero bytes named .pem read as a broken certificate rather than an absent one. There is no --json — the PEM is the output.

  • order revoke is the operator-side equivalent of POST /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.

    --reason is the RFC 5280 reason code: 0–6 or 8–10, since 7 is unused; any other value is refused. Omitted, the revocation carries no reason. For a relay or custom profile the revocation is queued for the running server, and --wait is how many seconds the command waits for it to land before returning (default 30); --wait 0 queues 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.

CommandFlags
jobs list--kind <k>, --status <s>, --limit <n>, --offset <n>, --json
jobs show <id>--json
jobs cancel <id>(prompts)
jobs run-now <id>—
  • jobs list is paged like the other listings, newest first. --status is one of ready, running, done, failed, cancelled and is refused by name — passed to SQL an unknown value answers “no rows”, which reads as “nothing is in that state”. --kind is not refused: a job kind is an open set, so a typo simply matches nothing.

  • jobs show prints 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 cancel retires a job (status = cancelled) and is confirm-gated. Two things it will not do:

    • A running job is refused — a runner owns it, and its lease will expire or it will settle. Wait it out, then cancel the resulting ready/failed row.
    • 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_issue job also abandons the ACME order: the local order is marked invalid (so the client stops polling), the upstream mapping is abandoned (so a restart does not resume it), and a certificate_issue_failed audit row is written naming you. This is the operator-side way to stop a relayed issuance that will never complete.

  • jobs run-now makes a job eligible immediately — it is picked up within jobs.poll_interval_ms (it does not wake the runner). On a ready job it just pulls run_at forward; on a failed job it grants exactly one more attempt (attempts is set to max_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

CommandFlags
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 list is paged like every listing; see Paging.
  • An unknown --event or --outcome is 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 show prints one field per line, omitting every field that was not recorded rather than rendering it empty.
  • audit cleanup is the only command in this binary that destroys audit history, so it is confirm-gated and its prompt names the row count. audit.retention_days runs 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

CommandFlags
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 explain executes your custom scripts 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 (sideEffects under --json).

That is also why explain is 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. show is 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

CommandFlags
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

CommandFlags
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".

CommandFlags
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 via ps. Omit --eab-kid entirely 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)

CommandFlags
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 create prints the generated HMAC secret once. It is stored but never shown again, so a lost secret is replaced, not recovered.
  • --profile binds 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 revoke takes effect immediately, with no restart: credentials are read from the live database on every newAccount.
  • eab delete removes the credential. By default its accounts are kept, but they no longer resolve to any credential, so every eab filter check refuses them. --deactivate-accounts also deactivates them and keeps their orders, so their certificates can still be revoked. --delete-accounts also deletes them and everything under them; like account 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 list is newest first and paged; see Paging. It reads the same query GET /api/eab and /ui/eab do, 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.

CommandFlags
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 --password flag and clap rejects one: argv is visible via ps and 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 passwd and admin user disable both 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 role set the operator’s privilege tier, which scopes what their web sessions may do — viewer reads only, operator adds every CA action, admin adds managing other operators. It does not restrict the CLI: the host is the trusted plane. An unknown value is refused by name, and admin user role revokes the operator’s sessions so a demotion takes effect at once. A row that predates the feature reads as admin. See Web Admin — Roles.
  • Usernames are stored lowercased, so Alice and alice cannot 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 behind Cache-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 of argv. What the shell is for is the case the panel cannot serve: totp reset is 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 show is 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 status says the same thing about the factor alone. It also shows the operator’s contact address and the recent login addresses that raise a “new address” notification.
  • --contact / admin user contact set 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 contact with no --contact, or an empty one, clears it. Notifications are delivered only when admin.enabled and [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 list shows 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. --session needs --user, since the fingerprint only names a row within one operator’s sessions. Both listings are paged; see Paging, which also has the reason admin user list is the one listing ordered oldest first.

See Web Admin — Users & Sessions for the full treatment.