knit is a single Go binary that manages ACME (Let's Encrypt) certificates from
a central host and distributes them to consuming nodes. It has two sides, run on
different machines:
- Central host (
add/remove/list/renew): keeps the certificate list in Postgres, renews certs via ACME using a DNS-01 challenge, and publishes each cert plus its watch metadata into Valkey. - Each consuming node (
watch): polls Valkey for those certificates, writes them to disk on change, and runs a locally configured reload command (e.g.caddy reload).watchnever connects to Postgres.
Distribution from the central host to the nodes is handled externally by Valkey
replication. knit does not implement replication or transport security; it
assumes the network (e.g. a WireGuard mesh) provides that.
- Postgres is the source of truth for the cert list and renewal bookkeeping, accessed only by the central commands. It never stores issued certificate or private-key bytes.
renewis the only writer to Valkey. Each pass it reconciles Postgres → Valkey: publishing/refreshing every enabled cert's value, maintaining an index SET, and pruning certs that were removed or disabled.watchrelies solely on Valkey. It reads the index to discover which keys to watch and gets each cert's material and file paths from the per-cert value. A node needs only its local Valkey replica — no Postgres, no central host.
Requires Go (latest stable). Builds as a static binary:
CGO_ENABLED=0 go build -o knit .| var | meaning | default | used by |
|---|---|---|---|
KNIT_DB_URL |
Postgres DSN | (required) | central commands only |
KNIT_VALKEY_URL |
Valkey connection string; supports auth and TLS (rediss://) |
(required) | all |
KNIT_INDEX_KEY |
Valkey SET key listing active certs | knit:index |
renew, watch |
KNIT_ACME_DIRECTORY |
ACME directory URL (point at LE staging for testing) | LE production | renew |
KNIT_ACME_EMAIL |
account email, used on first registration | — | renew |
KNIT_RENEW_THRESHOLD_DAYS |
renew when fewer than N days of validity remain | 30 |
renew |
KNIT_RENEW_INTERVAL |
renew daemon check interval |
12h |
renew |
KNIT_DNS_RESOLVERS |
comma-separated ip[:port] recursive resolvers for the DNS-01 propagation precheck (see below) |
system resolv.conf |
renew |
KNIT_DNS_TIMEOUT |
per-query DNS client timeout for the precheck (not the propagation wait) | lego default (10s) | renew |
KNIT_DNS_DISABLE_RECURSIVE_CHECK |
skip the cache-prone recursive precheck; rely on the authoritative check (see below) | false |
renew |
KNIT_WATCH_INTERVAL |
watch Valkey poll interval |
60s |
watch |
KNIT_RELOAD_CMD |
command watch runs once per pass when any cert changed; empty = no reload |
(empty) | watch |
KNIT_LOG_LEVEL |
debug / info / warn / error |
info |
all |
watch requires no Postgres configuration.
The DNS provider is selected per cert (the provider field) using
lego's built-in provider registry. Credentials
are supplied via environment variables following lego's own conventions, so
switching providers needs no code change. For example:
- deSEC:
DESEC_TOKEN - Cloudflare:
CLOUDFLARE_DNS_API_TOKEN
See the lego DNS provider docs for the variable names of other providers.
You can issue certificates for domains without giving knit any access to those
domains' DNS, by delegating the ACME challenge to a zone you do control (e.g. a
deSEC-hosted a5t.dev). This is handled entirely at the lego/DNS layer — knit
needs no special configuration and no code changes.
How it works: lego follows CNAMEs when solving the DNS-01 challenge (on by
default; only LEGO_DISABLE_CNAME_SUPPORT=true turns it off). The DNS provider
writes the TXT record at the CNAME-resolved name, so the record lands in your
delegation zone rather than in the certificate's own domain.
Setup:
-
Add the cert in knit using the REAL domain(s) — never the delegation target. lego discovers the target at runtime by following the CNAME.
knit add --domains example.com --provider desec \ --valkey-key knit:example.com \ --cert-path /etc/ssl/example/fullchain.pem \ --key-path /etc/ssl/example/privkey.pem
-
Create a static CNAME, once, in each real domain's zone (out of band — knit/lego never creates or touches this). Note the source label is
_acme-challenge(hyphen):_acme-challenge.example.com. CNAME _acme-challenge.example.acme.a5t.dev.The target name is your choice since it lives in your zone; only the
_acme-challenge.<domain>source label is fixed by ACME. -
Point the provider credentials at the delegation zone. Set
DESEC_TOKENto a token that controlsa5t.dev(deSEC resolves whethera5t.devoracme.a5t.devis the registered domain). The token's scope must allow both reading the responsible zone and writing the_acme-challenge.*TXT RRset.
With that in place, knit renew issues normally: lego writes the TXT into your
deSEC delegation zone, and the certificate is issued for example.com even
though knit has no access to example.com's DNS.
Operational notes: deSEC propagation can lag — lego polls and you can extend the
wait with DESEC_PROPAGATION_TIMEOUT. A too-narrow deSEC token policy is the
most common live failure. Verify the whole path with a dry run against Let's
Encrypt staging (KNIT_ACME_DIRECTORY) before switching to production.
Before telling the ACME server the challenge is ready, lego runs a local
propagation check. By default it requires the _acme-challenge TXT to be visible
on both the recursive resolvers from /etc/resolv.conf and the zone's
authoritative nameservers. The recursive step is the fragile one: a caching
resolver (e.g. a public recursor like 1.1.1.1) can return a stale TXT from
a previous attempt, so the precheck either passes on stale data or fights the
cache on rapid retries. knit exposes the relevant lego controls:
-
KNIT_DNS_RESOLVERS— override the recursive resolvers used by the precheck and by zone/CNAME lookups. Point them at uncached/authoritative resolution, e.g. the deSEC authoritative servers:KNIT_DNS_RESOLVERS=ns1.desec.io,ns2.desec.org # bare host → :53 appendedCaveat for CNAME-delegated certs: the resolvers you set must be able to resolve the entire
_acme-challengeCNAME chain. Authoritative-only servers can only follow a chain that stays within their zones. -
KNIT_DNS_DISABLE_RECURSIVE_CHECK=true— skip the cache-prone recursive TXT comparison and rely on the authoritative check, which queries the domain's own nameservers (uncached) for the challenge record. This is the most direct fix for the stale-cache failure. lego still resolves the challenge CNAME through the system recursive resolver, so this also sidesteps the CNAME-chain caveat above (a static CNAME is harmless to cache). -
KNIT_DNS_TIMEOUT— the per-query DNS client timeout for the precheck. This is not the propagation wait; to wait longer for the record to appear, use the provider's own knob (e.g.DESEC_PROPAGATION_TIMEOUT).
The provider's propagation/polling/TTL knobs already pass straight through to lego
from the environment — no knit setting needed. For deSEC: DESEC_PROPAGATION_TIMEOUT
(default 120s), DESEC_POLLING_INTERVAL (default 4s), and DESEC_TTL. Note that
deSEC enforces a per-account minimum TTL of 3600s by default, so lowering
DESEC_TTL below that to shrink the challenge-record collision window is rejected
by the API unless you've raised your account's TTL limit.
Insert or update a managed cert in Postgres (upsert on the unique domains set).
Writes Postgres only; the cert appears in Valkey after the next renew pass.
knit add \
--domains example.com,www.example.com \
--provider desec \
--valkey-key knit:example.com \
--cert-path /etc/ssl/example/fullchain.pem \
--key-path /etc/ssl/example/privkey.pemAll five flags are required.
Remove a managed cert by --id or --domains. The corresponding Valkey value
and index membership are cleaned up on the next renew pass.
knit remove --domains example.com,www.example.com
knit remove --id 3Print managed certs and their state: id, enabled, domains, provider, valkey_key, paths, not_after, last_renewed, last_error.
knit listReconcile Postgres → Valkey. Runs as a daemon by default, reconciling every
KNIT_RENEW_INTERVAL; --once performs a single pass and exits (for cron / a
systemd timer). This is the only command that writes Valkey.
Each pass: load enabled certs, issue/renew any within the renewal threshold (or
with no known expiry) via ACME DNS-01, publish every enabled cert's value +
metadata to its valkey_key, maintain the index SET, and prune Valkey entries
for certs that are no longer enabled/present. A single cert's failure is recorded
in last_error and never aborts the pass.
# one pass against Let's Encrypt staging
KNIT_DB_URL=postgres://... \
KNIT_VALKEY_URL=redis://... \
KNIT_ACME_DIRECTORY=https://acme-staging-v02.api.letsencrypt.org/directory \
KNIT_ACME_EMAIL=ops@example.com \
DESEC_TOKEN=... \
knit renew --oncePoll Valkey and write changed certs to disk. Runs as a daemon by default, polling
every KNIT_WATCH_INTERVAL; --once performs a single pass and exits. A single
watch process handles all certs. No Postgres access.
Each pass: read the index, GET each value, and where the on-disk files differ
from the published hash, write the fullchain (0644) and private key (0600)
atomically (temp file + rename in the destination directory). If anything changed
and KNIT_RELOAD_CMD is set, run it exactly once; a non-zero exit is logged but
does not crash the watcher.
KNIT_VALKEY_URL=redis://... \
KNIT_RELOAD_CMD='caddy reload' \
knit watchBoth daemons shut down gracefully on SIGINT/SIGTERM, finishing the current pass before exiting.
go test ./...The store package's tests are integration tests that run only when
KNIT_TEST_DB_URL points at a disposable Postgres database (they create/drop
knit_ tables and truncate them); they skip otherwise. All other packages —
including the renew reconcile loop and the watch loop — are unit-tested against
an in-memory Valkey, so the suite runs with no external services.