certy — SSL & domain expiry monitor
A multi-tenant monitor for SSL certificates and domain expiry. Checks from the outside, alerts before anything lapses — built from scratch on a no-framework PHP 8 stack and shipped to production.
Solo — frontend → backend → database → security → deployment → ops
An expired TLS certificate or a lapsed domain takes a service down with no warning — and it’s always a weekend. certy watches both for you and flags them well before they go.
It’s a real, production-deployed app, built end to end and on purpose: a from-scratch PHP stack with no framework, so every layer — routing, auth, the protocol checks, the deploy pipeline — is something I wrote and understand. The live app is linked above; the short tour is below.

// what it is
A multi-tenant web app: you add the hosts and domains you care about, and certy checks them on a schedule — then shows a colour-coded dashboard and emails tiered alerts at 30, 14, 7 and 1 days before expiry.
Every check happens from the outside, the way a browser or registrar sees it: a raw TLS handshake reads each certificate; a raw WHOIS query reads each domain’s registration. Nothing to install.
// the core flow
// built from scratch — on purpose
No Composer, no framework, no namespaces — about 5,000 lines of plain PHP. A hand-rolled regex router, a thin PDO wrapper, and a set of global helpers stand in for the framework.
The point was to understand what a framework abstracts — routing, auth, CSRF, the query layer — rather than just wire one up. It trades some velocity for control and a much clearer mental model of the whole system.
// architecture
// features
- ✓SSL + domain expiry checks, run from the outside
- ✓Live colour-coded dashboard, sorted worst-first
- ✓On-demand and scheduled scanning
- ✓Tiered email alerts (30 / 14 / 7 / 1 days), deduped
- ✓Per-target history, CSV export, admin overview
- ✓Auth: argon2id, email verification, optional Google/GitHub
- ✓Strict per-user isolation; dark mode
// deep dive — the checks
Certificates are read straight off a raw TLS socket and parsed with OpenSSL — real served-cert expiry, issuer and subject, exactly what a client would see.
Domains go over raw WHOIS on port 43, using a TLD→WHOIS-server map with an IANA fallback so it resolves the right registry for each extension. Status (healthy / warning / critical / expired / failed) is derived at render time, never stored, so it can’t drift from reality.
// deep dive — multi-tenant isolation
Isolation is an invariant, not a convention: every query that touches a target is scoped by its owner and returns a 404 on a miss, so “not yours” and “doesn’t exist” are indistinguishable. There’s no path that loads a record by id alone.
The dashboard reads a denormalised “last result” snapshot kept on each target, so the most-hit screen is a single indexed query that never joins the history table.
// deep dive — security
Built from scratch with no framework, none of the usual protections come for free — every guard is hand-written. The whole external surface is treated as hostile: the host a user supplies, the data that host sends back, and anyone on the public demo.
Because the scanner makes outbound connections to user-supplied hosts, it’s hardened against server-side request forgery by design. Before connecting, it resolves the host, refuses private / reserved / CGNAT addresses, and pins the connection to that verified public IP — so a name can’t be rebound to an internal or cloud-metadata address (e.g. 169.254.169.254) between the check and the connection — while SNI and certificate verification stay aimed at the hostname. WHOIS is protected the same way, including registry referral hops.
Layered on top: per-IP rate limiting on the expensive public endpoints (on-demand scans, demo login/reset, registration), CSV formula-injection escaping on exports, an HSTS header, and the demo account kept out of alert emails. Defence in depth, fail-closed by default.
// scheduled scanning at scale
A timer fires the scanner on a schedule. For small loads it just checks every due target in one process. To scale, discovery and execution split apart: one command finds the targets due for a re-check and drops them onto a database-backed queue, and any number of worker processes drain that queue in parallel.
The part I like is the claim — no MySQL SKIP LOCKED needed. Each worker stamps a unique token onto a batch of pending rows with a single ordered, limited UPDATE (InnoDB’s row locks serialise it), then reads back the rows carrying its token. No job is ever handed to two workers, and throughput scales with the number of workers.
// production & devops
It actually runs in production. Push to main triggers GitHub Actions, which SSHes into a Hetzner VPS (Ubuntu 24.04), pulls, and runs migrations automatically. Caddy terminates HTTPS with auto-renewing certificates; Cloudflare handles DNS; Resend sends the mail.
systemd timers run the scheduled scans, a nightly database backup, and a cleanup cron. One person owning the whole chain — frontend, backend, database, security, deployment and ops.
// honest reflection
Trade-offs worth naming: the no-framework rule costs velocity and means re-solving problems a framework gives you for free — a deliberate choice here, for control and learning, on a project kept intentionally small enough to fully understand.
A couple of product trade-offs were deliberate. The demo stays fully interactive — it can add and scan public hosts — rather than read-only, because that’s the point of a demo, and the SSRF guard plus rate limiting keep it safe (the same capability public checkers like SSL Labs offer). Targets aren’t ownership-verified either, which isn’t standard for certificate monitors and would cripple the core feature.
Known limitation, documented not hidden: the scanner resolves IPv4 only.