The lrok edge has two HTTPS-ish surfaces: *.lrok.io (HTTP forwarding for tunneled traffic) and tunnel.lrok.io (the yamux control-plane port the CLI dials in to). Traefik fronts the first one with a Let's Encrypt cert. The second one needs the same cert but on a different port — and I really didn't want to run a second ACME client.
The clean solution: read Traefik's acme.json.
What's in acme.json
Traefik stores its ACME state in a single JSON file (path configurable, default /data/acme.json). The structure varies by Traefik version, but the meaningful subset is:
{
"letsencrypt": {
"Account": { ... },
"Certificates": [
{
"domain": { "main": "tunnel.lrok.io", "sans": [] },
"certificate": "<base64 PEM chain>",
"key": "<base64 PEM key>"
}
]
}
}
Mounted into the lrok-edge container as read-only:
# docker-compose.yml
volumes:
- traefik_data:/certs:ro
Loading it on startup
type AcmeJSON struct {
LetsEncrypt struct {
Certificates []struct {
Domain struct {
Main string `json:"main"`
SANs []string `json:"sans"`
} `json:"domain"`
Certificate string `json:"certificate"`
Key string `json:"key"`
} `json:"Certificates"`
} `json:"letsencrypt"`
}
func loadCert(path, hostname string) (*tls.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil { return nil, err }
var a AcmeJSON
if err := json.Unmarshal(data, &a); err != nil { return nil, err }
for _, c := range a.LetsEncrypt.Certificates {
if c.Domain.Main != hostname { continue }
certPEM, _ := base64.StdEncoding.DecodeString(c.Certificate)
keyPEM, _ := base64.StdEncoding.DecodeString(c.Key)
kp, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil { return nil, err }
return &kp, nil
}
return nil, fmt.Errorf("no cert found for %s", hostname)
}
That's the load path. The yamux listener wraps a normal net.Listen with tls.NewListener, with a config that returns this cert from GetCertificate.
Watching for renewal
Traefik renews 30 days before expiry; the file rewrites with new content. If the lrok process keeps the original tls.Certificate in memory, eventually it'll serve an expired cert.
The fix is fsnotify:
watcher, _ := fsnotify.NewWatcher()
defer watcher.Close()
watcher.Add(certPath)
go func() {
for ev := range watcher.Events {
if ev.Op & (fsnotify.Write|fsnotify.Create) != 0 {
// reload the cert; swap atomically into the listener's tls.Config
newCert, err := loadCert(certPath, hostname)
if err == nil { provider.swap(newCert) }
}
}
}()
A handful of caveats:
- Traefik writes
acme.jsonatomically (write + rename). TheCreateevent fires on the rename;Writedoesn't. Watch for both. - Some kernels emit a
Removefollowed byCreateinstead. The watcher must re-add the path after the original is removed, or it'll go silent. acme.jsonpermissions are restrictive (0600) by Traefik default. The lrok-edge container runs as a UID that can read it, or we relax to 0640 with a shared group.
Why not just expose Traefik's API?
Traefik has a "router" abstraction that could front the yamux port too: define a TCP entrypoint, route by SNI, terminate TLS at Traefik, forward to the lrok process on a different internal port. That works.
Two reasons I went with the file-read approach instead:
Latency. The agent connection is already going through one TLS termination at Traefik. Adding a second hop (Traefik → lrok process) doubles the inner-loop reconnect cost. The file-read approach has lrok terminate TLS itself; the agent connects directly.
Operational simplicity. Adding a yamux router to Traefik means more YAML, more reload races, more "did Traefik pick up the new config" debugging. The file-read approach has zero Traefik config — Traefik manages the cert; lrok consumes it. One responsibility each.
What I'd watch for in 2026
Traefik occasionally renames or restructures acme.json between major versions. The Go struct above is pinned to Traefik 3.x. A future Traefik 4 might break it; the integration test in internal/tlsutil loads a real Traefik-generated acme.json to catch that early.
The whole thing in 120 lines
The implementation lives in the published source at internal/tlsutil/cert_provider.go. Watcher, atomic swap, error logging — all in one file. ~120 lines.
If you're building something with a similar shape (Traefik fronting a Go process that needs the same cert), copy-paste from there.