// blog

← all posts

Reading Traefik's acme.json without running Traefik

·7 min read·go · tls · infrastructure

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.json atomically (write + rename). The Create event fires on the rename; Write doesn't. Watch for both.
  • Some kernels emit a Remove followed by Create instead. The watcher must re-add the path after the original is removed, or it'll go silent.
  • acme.json permissions 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:

  1. 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.

  2. 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.

// try it yourself

lrok is a hosted reverse-tunnel: one command, public HTTPS URL, reserved subdomain on the free plan. $9/mo flat for unlimited.