Relay
The relay backend keeps this server answering ACME to its own clients while a
real upstream CA does the signing. It captures internal ACME requests and
fulfils them through an upstream external CA (Let’s Encrypt, ZeroSSL, or a
commercial CA).
How it works
There are two ACME conversations, and keeping them apart is the whole trick
to reading this page. acme-proxy is the server in one and the client in
the other. Each has its own account, its own account key, its own order and its
own challenges, and nothing crosses between them.
sequenceDiagram
autonumber
participant C as Internal client<br/>(certbot, acme.sh)
participant P as acme-proxy
participant U as Upstream CA<br/>(Let's Encrypt)
Note over C,P: Conversation 1 — acme-proxy is the SERVER.<br/>Client's own account key, client's own order.
C->>P: newOrder (example.internal)
P-->>C: 201, order pending
C->>P: trigger challenge
P->>C: validate control (http-01 / dns-01 / tls-alpn-01)
P-->>C: 200, challenge valid — order ready
C->>P: finalize (CSR)
Note over P,U: Conversation 2 — acme-proxy is the CLIENT.<br/>Its OWN upstream account key, a SECOND order.
P-->>C: 200, order processing
P->>U: newOrder (same identifiers)
U-->>P: 201, upstream order pending
alt challenge_strategy = dns01
P->>P: publish TXT via RFC 2136 + TSIG
else challenge_strategy = http01
P->>P: publish token on its own /.well-known route
else challenge_strategy = bypass
P->>U: trigger — the upstream already trusts this account
end
U->>P: validate (against the proxy's thumbprint)
P->>U: finalize (the client's CSR, relayed unchanged)
U-->>P: certificate
Note over C,P: Back in conversation 1.
C->>P: poll order
P-->>C: 200, order valid + certificate URL
Two consequences fall straight out of the diagram:
- The key authorization at the upstream uses the proxy’s own thumbprint, never the client’s. They are different accounts on different servers, so the client could not answer the upstream’s challenge even in principle.
finalizestaysprocessingfor longer. Every backend’s finalize answersprocessingand is signed by the worker, but here conversation 2 takes as long as the upstream takes, so the client polls for minutes rather than for the momentlocal_caneeds — see Core Concepts.
Challenge strategies
The strategy names the one challenge type the proxy answers upstream. A CA offers several — Let’s Encrypt currently poses four — and every type the strategy does not name is ignored, including ones this server has no implementation for at all. Nothing falls back: if the upstream authorization does not offer the type the strategy names, the relay says so and stops, rather than trying a type it could not finish.
dns01 (RFC 2136 TSIG)
This is the strategy that can prove a wildcard. The proxy intercepts internal
HTTP-01 or DNS-01 challenges, but to satisfy the external CA, the proxy solves
the external DNS-01 challenge itself. It does this using an rfc2136 provider
powered by hickory-proto. It securely authenticates with the DNS server using
TSIG (Transaction Signature) to publish the TXT record. Note: The TXT record
uses the thumbprint of the proxy’s upstream account key, not the internal
client’s key.
DNS alias mode
By default the record is written at _acme-challenge.<domain>, so the update
key needs write access to every zone a profile issues for. Alias mode moves the
record to one name in a zone set aside for it, the way acme.sh’s
--challenge-alias does. Delegate each domain once with a CNAME, then point
zone and challenge_alias at the alias zone:
_acme-challenge.www.example.com. CNAME _acme-challenge.acme-alias.net.
_acme-challenge.api.example.org. CNAME _acme-challenge.acme-alias.net.
[signer.relay.dns01]
challenge_alias = "acme-alias.net."
[signer.relay.dns01.rfc2136]
zone = "acme-alias.net."
The CA follows the CNAME itself; nothing changes on its side. Every domain of the profile shares the one record name, which is safe because values are added and removed one by one. The alias is configured rather than found by following the CNAME, because this server’s resolver need not see what the CA sees, and a record published at the wrong name invalidates the upstream authorization for good.
Whoever can write the alias name can pass dns-01 for every domain pointing at
it. Domains that must not share that power belong in separate profiles, each
with its own alias and key.
http01
The proxy answers the upstream’s http-01 challenge by serving the key
authorization itself, from a route on its own root router at
/.well-known/acme-challenge/<token>.
Like dns01, the value served is derived from this proxy’s upstream account
thumbprint, not the internal client’s — the two are different accounts on
different servers, so the client cannot answer it even in principle. Unlike
dns01, the body is the key authorization verbatim rather than its SHA-256
digest (RFC 8555 §8.3 versus §8.4).
This is the opposite direction of the inbound http-01
challenge, which is this server validating its own
clients. The two share the well-known path and nothing else.
Two constraints are worth knowing before choosing it:
- It needs a forwarder.
acme-proxydoes not open a second listener and does not bind port 80. The upstream CA fetcheshttp://<identifier>:80/.well-known/acme-challenge/<token>, so something must forward or redirect that path toacme-proxy— see Deploying the http-01 responder below. - It cannot prove a wildcard. Nothing answers HTTP on the name
*.example.com. An upstream authorization for a wildcard is refused with an error namingdns01, which is the strategy that can.
There is no [signer.relay.http01] table: setting challenge_strategy = "http01" is the whole configuration.
bypass
Used when the upstream CA implicitly trusts the proxy’s account (e.g., a commercial CA with pre-validated domains). The proxy simply tells the upstream “I have validated this”, bypassing external challenges entirely.
This is the one strategy that does not name a type: it triggers whatever the authorization offers. It prefers a challenge it could have satisfied had it needed to, since an upstream that validates out of band still decides against the challenge it was pointed at.
Deploying the http-01 responder
Only relevant with challenge_strategy = "http01".
The upstream CA fetches on port 80 of each name being issued, so put the
existing web server for that name in front of acme-proxy and hand it that one
path. Either shape works — RFC 8555 §8.3 explicitly permits following redirects,
and every real CA does, so the target need not share the name:
# nginx, on the name being certified. Proxy it:
location /.well-known/acme-challenge/ {
proxy_pass http://acme-proxy:3000;
}
# ...or just redirect it, which needs no upstream block:
location /.well-known/acme-challenge/ {
return 301 http://acme-proxy:3000$request_uri;
}
# Caddy
handle /.well-known/acme-challenge/* {
reverse_proxy acme-proxy:3000
}
# Traefik, as a dynamic-configuration router
http:
routers:
acme-challenge:
rule: "PathPrefix(`/.well-known/acme-challenge/`)"
service: acme-proxy
priority: 100
services:
acme-proxy:
loadBalancer:
servers:
- url: "http://acme-proxy:3000"
The route is mounted on the root router, beside GET /health — it is not
under a profile’s /profile/<name> prefix, carries no filter chain, mints no
nonce, and answers a plain 404 rather than an ACME problem document for an
unknown token. It exists only while some profile’s signer uses this strategy;
with any other backend the path is not routed at all. A
http_01_responder_mounted line at startup confirms it is live.
Configuration
[signer]
backend = "relay"
[signer.relay]
directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
account_key_path = "upstream_account.key"
contact = ["mailto:admin@example.com"]
challenge_strategy = "bypass"
poll_interval_ms = 2000
poll_timeout_secs = 300
Several relaying profiles
[signer] is a per-profile section, so one server can relay to several
upstreams at once — a Let’s Encrypt endpoint beside a commercial CA, or one
internal CA per environment. Each profile gets its own [signer.relay], and
they must not share an account_key_path: two backends over one upstream
account key would overwrite each other’s registration, and startup refuses it
by name.
[profiles.public.signer]
backend = "relay"
relay.directory_url = "https://acme-v02.api.letsencrypt.org/directory"
relay.account_key_path = "public_account.key"
[profiles.partner.signer]
backend = "relay"
relay.directory_url = "https://acme.commercial-ca.example/directory"
relay.account_key_path = "partner_account.key"
Two profiles whose [signer] sections are byte-for-byte identical share one
backend and one upstream account; anything that differs makes them independent.
See Profiles for what else a profile separates.
Reference
directory_url (String) — Default: "" | Env: ACME_PROXY_SIGNER__RELAY__DIRECTORY_URL
The upstream ACME server’s directory URL.
Starting the server registers an account. The first
acme-proxy servewith this backend configured contactsdirectory_urland performsnewAccountthere, writing the assigned account URL to the.kidsidecar. There is no confirmation step and no dry-run — merely booting a configuration that names a production CA creates a real account at it, and account creation is itself rate limited (Let’s Encrypt allows 10 per IP address per 3 hours). Pointdirectory_urlat a staging endpoint (https://acme-staging-v02.api.letsencrypt.org/directory) while you are still working out a configuration, and switch to production only once it is settled. Subsequent starts reuse the.kidsidecar and do not contact the upstream.
account_key_path (String) — Default: "upstream_account.key" | Env: ACME_PROXY_SIGNER__RELAY__ACCOUNT_KEY_PATH
Path to this proxy’s own account key at the upstream CA. If the file is absent,
an ECDSA P-256 key is generated on startup. The assigned kid is stored beside
it with a .kid extension.
contact (Array) — Default: [] | Env: ACME_PROXY_SIGNER__RELAY__CONTACT
Optional contacts sent with newAccount to the upstream CA.
challenge_strategy (String) — Default: "bypass" | Env: ACME_PROXY_SIGNER__RELAY__CHALLENGE_STRATEGY
How the proxy satisfies the upstream’s domain-control checks: bypass (the
upstream validates nothing), dns01 (publish the TXT record the upstream asks
for) or http01 (serve the challenge file from this server’s own root router,
which requires a reverse proxy in front of it and cannot prove a wildcard). Any
other value is a startup error.
poll_interval_ms (Integer) — Default: 2000 | Env: ACME_PROXY_SIGNER__RELAY__POLL_INTERVAL_MS
How often to poll an upstream order/authorization while it resolves.
poll_timeout_secs (Integer) — Default: 300 | Env: ACME_PROXY_SIGNER__RELAY__POLL_TIMEOUT_SECS
Total budget (in seconds) for one upstream issuance before the local order is marked invalid.
[signer.relay.dns01]
Only consulted when challenge_strategy = "dns01".
provider (String) — Default: "rfc2136" | Env: ACME_PROXY_SIGNER__RELAY__DNS01__PROVIDER
DNS provider used to publish the upstream TXT record. rfc2136 is currently the
only implementation.
challenge_alias (String) — Default: "" | Env: ACME_PROXY_SIGNER__RELAY__DNS01__CHALLENGE_ALIAS
A domain, e.g. acme-alias.net., under which every challenge record is
published as _acme-challenge.<alias> — see DNS alias mode.
Empty publishes at each domain’s own _acme-challenge name. The alias must lie
inside rfc2136.zone; one outside it, a wildcard, or a value that already
starts with _acme-challenge. is a startup error.
[signer.relay.dns01.rfc2136]
All default to "" and are required once the dns01 strategy is selected.
server — Env: ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__SERVER
host:port of the nameserver accepting the dynamic update, e.g. 10.0.0.53:53.
This is the update target, distinct from dns.resolver, which governs
lookups.
zone — Env: ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__ZONE
The zone the update is sent for, fully qualified with a trailing dot, e.g.
internal.company.com..
tsig_key_name — Env: ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_NAME
Name of the TSIG key the update is signed with.
tsig_key_secret — Env: ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_SECRET
The TSIG shared secret, in standard base64 — note this differs from EAB
secrets, which are base64url. A value that is not valid base64 is a startup
error, not a runtime one. This key is legitimately long-lived, so unlike a
one-shot EAB credential it belongs in configuration; still prefer the
environment variable over a file on disk.
tsig_algorithm (String) — Default: "" (read as hmac-sha256) | Env: ACME_PROXY_SIGNER__RELAY__DNS01__RFC2136__TSIG_ALGORITHM
The TSIG algorithm: hmac-sha256, hmac-sha384 or hmac-sha512. Must match
the key as your nameserver defines it.
Records are added and removed by value, so other TXT values at the same name —
another order’s for that name, or ones this server did not write — are left
alone. The challenge name must lie inside zone; one outside it is refused
before anything is sent.
Updates are sent over UDP and retried over TCP when the response is truncated —
a TSIG-signed update readily exceeds 512 bytes, so the TCP path is a normal
occurrence rather than an edge case.
Only a UDP answer from server itself is accepted.
A successful answer counts only when it is TSIG-signed by the configured key,
for this update, within the server’s time window (RFC 8945); anything else fails
the update. A refusal is reported as the server sent it, with its TSIG error
explained: BADKEY means the server does not know tsig_key_name under
tsig_algorithm, BADSIG that tsig_key_secret does not match, and BADTIME
that the two clocks disagree.
[signer.relay.dns01.propagation]
What the dns01 strategy waits for between publishing the TXT record and
asking the upstream to validate it. The upstream looks once: a record it cannot
see yet makes the authorization invalid for good, which fails the client’s
order and, at a public CA, counts against its failed-validation limit.
mode (String) — Default: "none" | Env: ACME_PROXY_SIGNER__RELAY__DNS01__PROPAGATION__MODE
none triggers the challenge right after the update succeeds, which is right
when the update server is itself what the CA asks. delay sleeps delay_secs
first, for a provider that accepts an update before serving it (a DNS API behind
an RFC 2136 bridge) or secondaries that lag their primary. Any other value is a
startup error.
delay_secs (u64) — Default: 30 | Env: ACME_PROXY_SIGNER__RELAY__DNS01__PROPAGATION__DELAY_SECS
Seconds to wait under delay; ignored under none. Zero is refused (use
none), and so is a value not less than poll_timeout_secs, which bounds the
whole relay attempt. The delay runs once for each name in the order, so a
multi-name order needs poll_timeout_secs above the delay times the number of
names plus the time the upstream takes.
The wait is a fixed delay rather than a poll of a public resolver on purpose. A CA resolves through the zone’s authoritative nameservers itself, while a recursive resolver answers from whichever one it reached and caches a “no such record” from a lagging one for the zone’s negative TTL — and never sees an internal or split-horizon zone at all.
[signer.relay.eab]
An upstream External Account Binding credential supplied in configuration
rather than through acme-proxy upstream register. Both keys are empty by
default, which means “no configuration-file credential”. Read only by
acme-proxy serve, and only on a startup that finds no .kid sidecar beside
account_key_path — see EAB considerations below for
which of the two mechanisms to prefer.
kid (String) — Default: "" | Env: ACME_PROXY_SIGNER__RELAY__EAB__KID
The key id the upstream’s operator issued alongside the secret.
hmac_key (String) — Default: "" | Env: ACME_PROXY_SIGNER__RELAY__EAB__HMAC_KEY
Sensitive. The shared secret, in base64 — url-safe, unpadded url-safe or
standard are all accepted, the same three forms acme-proxy upstream register
takes. A value that decodes as none of them is a startup error, as is
setting either key without the other. Prefer the environment variable to a file
on disk, and clear it once registration has succeeded: while it stays non-empty
the server logs a signer_relay_eab_secret_in_config warning on every startup.
EAB considerations
An External Account Binding (EAB) credential is a one-time use token that
authorizes a single newAccount request and is useless afterwards —
registration itself only ever runs once, guarded by the .kid sidecar that ends
up next to account_key_path. There are two ways to supply it:
The Admin CLI, registering the proxy with the upstream CA out of band:
acme-proxy upstream register --profile prod --eab-kid "..." --eab-hmac-key-file /path/to/secret
The secret may also be piped on stdin. It is never accepted as a command-line
argument, because argv is visible to every user on the host via ps. Nothing
about this credential is ever written to disk — only the resulting account kid
persists.
--profile is required whenever the configuration defines more than one
profile: [signer] is a per-profile section, so registering “the upstream”
without saying which one would be registering nothing.
[signer.relay.eab] in configuration, read by acme-proxy serve
itself on the first startup with no .kid sidecar yet:
[signer.relay.eab]
kid = "..."
hmac_key = "..." # base64: url-safe, unpadded url-safe, or standard
This is the trade-off the CLI path exists to avoid: a bootstrap secret sitting
in configuration for the life of the server, in exchange for not needing a
separate imperative step — useful when config.toml is already populated by a
secrets manager or a templated deployment. Once registration succeeds, serve
logs a signer_relay_eab_secret_in_config warning on every startup for
as long as hmac_key stays non-empty, the same treatment challenge.bypass and
ipam.netbox.insecure_skip_verify get — clear it out once acme-proxy upstream show confirms a kid is stored. Setting kid without hmac_key, or
vice versa, is a startup error.
If the upstream requires EAB and neither mechanism supplies a working
credential, acme-proxy serve fails at startup naming both.
The outbound client validates the upstream’s TLS certificate against
webpki-roots — unlike challenge.http_01, which deliberately does not
validate the responder’s certificate. Here the certificate is the only thing
identifying the CA being handed your CSRs.