Skip to content

Certificate

Field Type Required Notes
type string yes custom or acme.
domains []string yes Domains the cert covers (*.example.com for wildcard).
acme ACMESpec when type: acme
custom CustomCertSpec when type: custom

ACMESpec

Field Type Required Notes
email string yes ACME account contact. Let's Encrypt stopped sending expiration reminders in 2025; this is used for account/policy notices only.
challenge string no dns-01 or http-01. Default: dns-01 when dnsProvider is set (so configs written before this field existed keep their behaviour), http-01 otherwise.
dnsProvider string for dns-01 A DNSProvider name. Rejected with http-01.
directoryURL string no Defaults to Let's Encrypt production.
keyType string no ecdsa only (default when empty). rsa is not implemented and is rejected at config validation time.
eab EABSpec no External Account Binding, for CAs that require it.

EABSpec: kid (the key id the CA issued) and hmacKey (Secret; base64url as the CA issued it). Both are required together. An EAB key id widens the ACME account identity, so two external accounts on the same CA get separate account keys under <cert-dir>/acme/accounts/.

Challenge selection:

  • http-01: validated on gpm's own plaintext :80 proxy listener. The ACME manager parks the in-flight token in memory and the listener answers /.well-known/acme-challenge/<token> before host routing, the force-SSL redirect, and any auth, so a certificate can be issued for a host that does not exist yet or that redirects everything to https. A challenge path whose token is not in flight is routed normally, so an upstream running its own ACME client keeps working. Port 80 must be reachable from the internet.
  • dns-01: the only challenge that can prove a wildcard. A wildcard domain with http-01 (explicit or defaulted) is a validation error.
# ZeroSSL with External Account Binding, http-01
name: zerossl
type: acme
domains: [app.example.com]
acme:
  email: admin@example.com
  challenge: http-01
  directoryURL: https://acme.zerossl.com/v2/DV90
  eab:
    kid: ${ENV:ZEROSSL_EAB_KID}
    hmacKey: ${ENV:ZEROSSL_EAB_HMAC}
# Google Public CA (EAB required; kid + hmacKey come from `gcloud publicca`)
acme:
  email: admin@example.com
  challenge: http-01
  directoryURL: https://dv.acme-v02.api.pki.goog/directory
  eab:
    kid: ${ENV:GOOGLE_CA_EAB_KID}
    hmacKey: ${FILE:/run/secrets/google_ca_eab_hmac}

CustomCertSpec: certFile, keyFile: paths relative to the cert store (absolute paths and .. are rejected). These are file references, not inline PEM.

# ACME wildcard
name: wildcard
type: acme
domains: ["*.example.com", example.com]
acme:
  email: admin@example.com
  dnsProvider: cloudflare
  keyType: ecdsa
# ACME single name over http-01 (no DNS provider needed)
name: app
type: acme
domains: [app.example.com]
acme:
  email: admin@example.com
  challenge: http-01
# Bring-your-own
name: internal
type: custom
domains: [internal.example.com]
custom: {certFile: internal.crt, keyFile: internal.key}

Status fields (GET only)

GET /api/certificates and GET /api/certificates/{name} decorate every stored object with these read-only fields, computed from the certificate store on disk (lastError/lastAttempt come from the ACME manager's in-memory state instead). None are accepted on write and none are stored in the YAML.

Field Type Notes
notBefore string (RFC 3339) Leaf certificate's validity start. Absent for an acme certificate with no completed order yet (state: pending).
notAfter string (RFC 3339) Leaf certificate's expiry. Absent alongside notBefore.
daysRemaining int Whole days until notAfter, negative once expired. Absent alongside notAfter.
issuer string Leaf certificate's issuer common name. Absent alongside notAfter.
state string valid, expiring (at or inside 30 days), expired, pending (acme, no order has completed yet) or error (last attempt failed, see lastError).
lastError string Most recent renewal (or, for custom, file-read) failure. Empty or absent means the last attempt succeeded. The text depends on the caller: an admin gets the raw ACME or file/parse message truncated to 300 characters; any non-admin caller (the read-only user role, or a certificates:read token) gets the same short classified reason GET /api/health uses, e.g. dns-01 challenge or DNS provider failure. The raw text can embed a third party's response body, so it is not disclosed more widely than /health does.
lastAttempt string (RFC 3339) When the ACME manager last attempted to issue/renew this certificate. acme certificates only; absent on an HA follower, which does not run the manager.
sans []string Leaf certificate's subject alternative names (DNS names and IPs).

Force an immediate renewal, ignoring the 30-day renewal window, with POST /api/certificates/{name}/renew (certificates:write scope). It responds {"started": true} once the order has started, not once it completes: DNS-01 propagation alone can take minutes. Poll the status fields above for the outcome.

Two things answer 409, and the error message says which:

Cause Message Fix
Another order is in flight anywhere on this instance Names the in-flight order; nothing is queued Wait for it and retry
This certificate is inside its 1-hour renew cooldown, counted from lastAttempt (a failed attempt starts it too) States the remaining wait, e.g. retry in 42m10s Wait it out, and read lastError meanwhile

400 is a custom certificate (gpm does not renew those, replace the file and PUT the object); 501 is an instance that is not the ACME issuer. See Certificate health for the operational walkthrough and GET /api/health for the fleet-wide summary.

Which certificate a host serves

Selection is by SNI, not by the host's tls.certificateRef:

  • Every enabled Certificate object is loaded into one SNI map at compile time.
  • At handshake, an exact match on the client's server_name wins; otherwise a *.parent wildcard one label up wins; otherwise the handshake fails.
  • An ACME certificate that has not been issued yet is skipped until the manager issues it.

Where tls.certificateRef is honoured:

Host kind tls.certificateRef What actually selects the certificate
ProxyHost, RedirectHost, ParkedHost Checked for existence only. Ignored at request time. SNI, across every certificate.
StreamHost with tls.mode: terminate Authoritative and required. The named certificate, and only it.
StreamHost with tls.mode: passthrough Forbidden. Nothing, gpm never terminates.

For an L7 host the field is an intent record: useful documentation of which certificate you expect to cover the host, and nothing more. gpm logs a warning at load time when a proxy, redirect or parked host names a certificate that covers none of its domains, because that config serves a different certificate (or fails the handshake) than the field implies:

proxy host "app" sets tls.certificateRef to certificate "wildcard-other", which
covers none of its domains (app.example.com); proxy host certificates are
selected by SNI across every certificate ...

It is a warning, not an error: the config is legal, and the certificate that does cover the domain still serves the host.