Apache-2.0 · self-hosted · active development

Every certificate
for your domains.
Nothing you didn't expect.

Merkleye is a self-hosted Certificate Transparency watchtower. It tails the CT log firehose in real time, flags certificates from issuers you never authorized, and catches lookalike-domain certificates — often the earliest signal of phishing infrastructure going live — before anyone clicks a link.

20–30M
CT log entries tailed per day
100k+
lookalike domains watched per sweep
0
third parties see your domain list
merkleye — findings
# cert observed on a CT log, seconds after issuance
domain:   secure-paypaI.com  ← homoglyph of paypal.com
issuer:   Let's Encrypt (unexpected: not authorized)
source:   oak2024h1.ct.letsencrypt.org
dedup:    sha256(der) 8f21…a04c
match:    dnstwist variant, eTLD+1 reduced
score:    87 / 100  (additive, explainable)
notify:   slack, webhook  → sent in 0.4s

Why Merkleye

Built around three things a watchtower can't compromise on.

01

Security

Self-hosted by design — your domain list and findings never leave infrastructure you control.

  • No SaaS relay: run it on a homelab box or an enterprise cluster, same image.
  • Apache-2.0 and auditable — the matching and scoring logic isn't a black box you have to trust blind.
  • Config-as-code: config.yaml is the sole write authority in v1, so there's no UI/DB drift to reason about during an incident.
02

Speed

Firehose ingestion means detection latency is bounded by CT log propagation, not by how often you remember to query.

  • Tails every RFC6962 and static-ct-api log continuously via a certspotter sidecar — no polling, no per-domain API calls.
  • Every certificate is tested against your hash set as it's observed, not on a batch schedule.
  • Notifications fire (SMTP, webhook, Discord, Slack) the moment a match clears policy — while the phishing infra is still being staged.
03

Accuracy

Tuned to keep the signal-to-noise ratio survivable: routine renewals stay quiet, real lookalikes don't slip through.

  • Expected-issuer policy is a case-insensitive substring match on issuer DN/O — CA intermediates rotate silently, so exact match would page you on every renewal.
  • Dedup keys on sha256(DER), never the certificate serial — serials collide across CAs and would under-count real issuances.
  • Domains normalize to punycode/A-label before comparison, matching how SANs are actually encoded on the wire.

Architecture differentiator

Firehose ingestion, not query-per-domain.

Watching lookalikes at scale means watching permutations, not just domains: 20 real domains fed through dnstwist becomes roughly 100,000 lookalike variants to monitor. A query-per-domain API model needs 100,000 requests to sweep that set once. Merkleye doesn't query at all — it tails the CT log stream continuously via SSLMate's certspotter sidecar and tests every certificate it sees against your hash set in-line. That's what makes watching six figures of variants architecturally tractable instead of a rate-limit problem.

Global CT issuance runs roughly 20–30 million entries a day. Merkleye discards everything that doesn't match — only matches are persisted, so your storage footprint tracks your hit rate, not the internet's issuance volume.

query-per-domainfirehose (Merkleye)
100,000
API requests per sweep
1
continuous log stream
CT issuance, global~20–30M / day
Stored by Merkleyematches only

Pipeline

From log entry to Slack message, one straight line.

No queue of domains to poll, no scheduled crawl. Every stage runs as certificates arrive on the log stream.

01

CT logs

Every RFC6962 and static-ct-api log, worldwide.

02

certspotter sidecar

SSLMate's certspotter tails each log continuously — no polling.

03

Normalize · dedup · match

A-label/punycode normalization, sha256(DER) dedup, PSL eTLD+1 reduction, hash-set match.

04

Policy & score

Expected-issuer substring check, transparent additive risk score.

05

Persist

Only matches land in Postgres — non-matches are discarded.

06

Notify

SMTP, webhook, Discord, Slack — deduplicated and rate-limited.

Observability included:OpenTelemetry tracing over OTLP/gRPC across every stage, ready to feed a Grafana · Prometheus · Loki · Tempo stack for enterprise deployments — or ignore it entirely on a homelab box.

Read the internals →

Correctness

The details that keep false positives — and false negatives — down.

None of this is glamorous. All of it is the difference between a watchtower you trust and one you tune out.

Dedup on sha256(DER), never serial

Certificate serial numbers collide across CAs. Merkleye dedups on the SHA-256 hash of the DER-encoded certificate — the one identifier that's actually unique.

Punycode/A-label normalization

IDN homoglyph domains are Unicode, but SAN entries are A-label-encoded on the wire. Comparing Unicode against a wire-format SAN matches nothing and fails silently — everything is normalized to A-label before comparison.

Public Suffix List aware

Phishing certificates live on subdomains. Variant lookups reduce through the PSL to eTLD+1 first, so login.secure-paypaI.com still resolves back to the watched variant.

Transparent, additive scoring

Risk scoring (internal/score) is a documented, additive rule set — not a black-box model. Every score is explainable back to the specific signals that produced it.

Self-hosting

One container image, homelab to enterprise.

Same image whether it's a single container next to a Postgres instance on a homelab box, or a cluster deployment with an OTel collector and a full Grafana stack behind it. Configuration is code:config.yaml is the sole write authority in v1 — deliberately, to avoid drift between a config file and a database or UI.

docker-compose.yml
services:
  merkleye:
    image: ghcr.io/wesleykirkland/merkleye:latest
    restart: unless-stopped
    volumes:
      - ./config.yaml:/etc/merkleye/config.yaml:ro
    depends_on:
      - postgres

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: merkleye
    volumes:
      - merkleye-db:/var/lib/postgresql/data

volumes:
  merkleye-db:
config.yaml
domains:
  - name: example.com
    expected_issuers:
      - "Let's Encrypt"   # substring match on issuer DN/O
      - "DigiCert"
    watch_lookalikes: true

notify:
  slack:
    webhook_url: ${SLACK_WEBHOOK_URL}
Full self-hosting docs →docker compose up -d — that's the whole install.