the shed // tls & pki

A lapsed certificate is one of the few outages that’s entirely predictable and entirely preventable — if something actually checks the expiry date before it becomes an incident. Here’s a concurrent, dependency-free Ruby monitor that does exactly that.

Step through the build below:




cert_expiry_monitor.rb

Expired TLS certificates are one of the few outages that are entirely self-inflicted.
Unlike a traffic spike or a bad deploy, a certificate’s expiry date is known the moment it’s issued —
there’s no reason a team should find out it lapsed from a customer’s screenshot of a browser’s
“Your connection is not private” page.

The trouble is that most teams don’t have an easy way to see every certificate they’re
responsible for in one place. Certs get provisioned by different teams, on different hosts, through
different automation (or none at all), and renewal reminders live in someone’s calendar rather than
in a system that actually checks. This script connects to a list of host:port targets,
completes a real TLS handshake, and reads the certificate the server is actually presenting right
now
— not a cached copy, not what a CA’s dashboard says was issued. If a host is quietly
serving a cert that expires next week, this is what would tell you.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cert_expiry_monitor.rb
#
# Pure Ruby TLS certificate expiry monitor. Connects to a list of
# host[:port] targets, pulls the live leaf certificate presented during
# the TLS handshake (NOT a cached/local copy), and reports how many days
# remain until it expires. No gems required -- just openssl and socket
# from the Ruby standard library.
#
# Exit codes (cron/monitoring friendly):
#   0 -> everything OK
#   1 -> at least one WARN (approaching expiry)
#   2 -> at least one CRIT (expired, about to expire, or unreachable)
#
# Usage:
#   ruby cert_expiry_monitor.rb HOST[:PORT] [HOST[:PORT] ...] [options]
#
# Options:
#   --warn-days N     Days-remaining threshold for WARN   (default: 30)
#   --crit-days N     Days-remaining threshold for CRIT   (default: 7)
#   --timeout N       Per-connection timeout in seconds   (default: 5)
#   --json            Emit machine-readable JSON instead of text
#
require 'openssl'
require 'socket'
require 'optparse'
require 'json'
require 'timeout'
require 'time'
# ---------------------------------------------------------------------------
# CertCheck: connects to a single host:port, performs a TLS handshake, and
# extracts expiry info from the certificate the server actually presents.
# ---------------------------------------------------------------------------
class CertCheck
  Result = Struct.new(:target, :host, :port, :status, :days_remaining,
                       :not_after, :subject, :issuer, :error, keyword_init: true)
  def initialize(target, timeout: 5)
    @target = target
    @host, port_str = target.split(':', 2)
    @port = (port_str || 443).to_i
    @timeout = timeout
  end
  # Opens a raw TCP socket, wraps it in an SSLSocket with SNI set (so
  # name-based virtual hosts serve the right cert), completes the
  # handshake, and reads back the peer certificate. Everything is
  # wrapped in Timeout so a single hung host can't stall the whole run.
  def call
    Timeout.timeout(@timeout) do
      tcp = TCPSocket.new(@host, @port)
      begin
        ctx = OpenSSL::SSL::SSLContext.new
        # We want the cert even if it's untrusted/self-signed/expired --
        # that's exactly the case this tool needs to detect and report,
        # not silently reject.
        ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
        ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
        ssl.hostname = @host # SNI
        ssl.connect
        cert = ssl.peer_cert
        ssl.sysclose
        return build_result(cert)
      ensure
        tcp.close unless tcp.closed?
      end
    end
  rescue StandardError, Timeout::Error => e
    Result.new(target: @target, host: @host, port: @port, status: 'CRIT',
               days_remaining: nil, not_after: nil, subject: nil, issuer: nil,
               error: "#{e.class}: #{e.message}")
  end
  private
  def build_result(cert)
    if cert.nil?
      return Result.new(target: @target, host: @host, port: @port, status: 'CRIT',
                         days_remaining: nil, not_after: nil, subject: nil, issuer: nil,
                         error: 'server presented no certificate')
    end
    days_remaining = ((cert.not_after - Time.now) / 86_400).floor
    Result.new(
      target: @target, host: @host, port: @port,
      status: nil, # classified by caller against thresholds
      days_remaining: days_remaining,
      not_after: cert.not_after.utc.iso8601,
      subject: cert.subject.to_a.find { |name, _, _| name == 'CN' }&.at(1) || cert.subject.to_s,
      issuer: cert.issuer.to_a.find { |name, _, _| name == 'CN' }&.at(1) || cert.issuer.to_s,
      error: nil
    )
  end
end
# ---------------------------------------------------------------------------
# Runner: fans checks out across a small thread pool (TLS handshakes are
# I/O-bound, so threads are a good fit and keep the total wall-clock time
# close to that of the single slowest host, not the sum of all hosts).
# ---------------------------------------------------------------------------
class Runner
  def initialize(targets, warn_days:, crit_days:, timeout:, concurrency: 8)
    @targets = targets
    @warn_days = warn_days
    @crit_days = crit_days
    @timeout = timeout
    @concurrency = concurrency
  end
  def run
    queue = Queue.new
    @targets.each { |t| queue << t }
    results = Queue.new
    workers = Array.new([@concurrency, @targets.size].min) do
      Thread.new do
        until queue.empty?
          target = begin
            queue.pop(true)
          rescue ThreadError
            nil
          end
          next unless target
          result = CertCheck.new(target, timeout: @timeout).call
          classify!(result)
          results << result
        end
      end
    end
    workers.each(&:join)
    drained = []
    drained << results.pop until results.empty?
    # Preserve the order the targets were given on the command line.
    @targets.map { |t| drained.find { |r| r.target == t } }
  end
  private
  def classify!(result)
    return if result.status == 'CRIT' && result.error # unreachable/handshake failure
    if result.days_remaining.nil?
      result.status = 'CRIT'
    elsif result.days_remaining <= @crit_days
      result.status = 'CRIT'
    elsif result.days_remaining <= @warn_days
      result.status = 'WARN'
    else
      result.status = 'OK'
    end
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  options = { warn_days: 30, crit_days: 7, timeout: 5, json: false }
  parser = OptionParser.new do |opts|
    opts.banner = 'Usage: ruby cert_expiry_monitor.rb HOST[:PORT] [HOST[:PORT] ...] [options]'
    opts.on('--warn-days N', Integer, 'Days-remaining threshold for WARN (default: 30)') { |v| options[:warn_days] = v }
    opts.on('--crit-days N', Integer, 'Days-remaining threshold for CRIT (default: 7)') { |v| options[:crit_days] = v }
    opts.on('--timeout N', Integer, 'Per-connection timeout in seconds (default: 5)') { |v| options[:timeout] = v }
    opts.on('--json', 'Emit machine-readable JSON') { options[:json] = true }
    opts.on('-h', '--help', 'Show this help') do
      puts opts
      exit 0
    end
  end
  targets = parser.parse(ARGV)
  if targets.empty?
    warn parser
    exit 2
  end
  results = Runner.new(targets, warn_days: options[:warn_days],
                                 crit_days: options[:crit_days],
                                 timeout: options[:timeout]).run
  if options[:json]
    puts JSON.pretty_generate(results.map(&:to_h))
  else
    printf("%-32s %-6s %-8s %-22s %s\n", 'TARGET', 'STATUS', 'DAYS', 'EXPIRES (UTC)', 'SUBJECT / ERROR')
    puts '-' * 100
    results.each do |r|
      detail = r.error || r.subject
      printf("%-32s %-6s %-8s %-22s %s\n", r.target, r.status, r.days_remaining.nil? ? '-' : r.days_remaining,
             r.not_after || '-', detail)
    end
  end
  worst = results.map(&:status).max_by { |s| { 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[s] }
  exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[worst] || 2)
end

Two design choices are worth calling out. First, the TLS context is created with
verify_mode = OpenSSL::SSL::VERIFY_NONE. That looks alarming out of context, but it’s
deliberate: this tool’s entire job is to inspect certificates that might be untrusted, expired, or
self-signed, so refusing to complete the handshake with anything but a perfectly valid chain would
defeat the purpose. The script never sends or trusts any data over this connection — it opens
the connection, reads the certificate, and closes it.

Second, checks run concurrently across a small thread pool rather than one host at a time. A TLS
handshake is dominated by network round trips, not CPU, so Ruby’s threads (even under the GVL) are a
good fit: the wall-clock time for checking 20 hosts ends up close to the slowest single host, not the
sum of all 20. Each result is classified independently against the --warn-days /
--crit-days thresholds, and the run’s exit code is the worst status found —
so this drops straight into cron or a monitoring pipeline without any extra wrapping.

+ ruby cert_expiry_monitor.rb 127.0.0.1:9101 127.0.0.1:9102 127.0.0.1:9103 127.0.0.1:9199 --warn-days 30 --crit-days 7
TARGET                           STATUS DAYS     EXPIRES (UTC)          SUBJECT / ERROR
----------------------------------------------------------------------------------------------------
127.0.0.1:9101                   OK     59       2026-10-03T17:23:49Z   localhost
127.0.0.1:9102                   WARN   14       2026-08-19T17:23:49Z   localhost
127.0.0.1:9103                   CRIT   2        2026-08-07T17:23:49Z   localhost
127.0.0.1:9199                   CRIT   -        -                      Errno::ECONNREFUSED: Connection refused - connect(2) for "127.0.0.1" port 9199
exit code: 2
--- JSON mode ---
$ ruby cert_expiry_monitor.rb 127.0.0.1:9101 127.0.0.1:9102 127.0.0.1:9103 --json
[
  {
    "target": "127.0.0.1:9101",
    "status": "OK",
    "days_remaining": 59,
    "not_after": "2026-10-03T17:23:49Z",
    "subject": "localhost",
    "error": null
  },
  {
    "target": "127.0.0.1:9102",
    "status": "WARN",
    "days_remaining": 14,
    "not_after": "2026-08-19T17:23:49Z",
    "subject": "localhost",
    "error": null
  },
  {
    "target": "127.0.0.1:9103",
    "status": "CRIT",
    "days_remaining": 2,
    "not_after": "2026-08-07T17:23:49Z",
    "subject": "localhost",
    "error": null
  }
]
# This run was captured against three local OpenSSL::SSL::SSLServer instances
# serving self-signed certs with 60/15/3-day expiry windows, plus one closed
# port -- so every branch (OK, WARN, CRIT, unreachable) is exercised for real,
# without depending on any live public host's certificate happening to be
# near expiry the day this ran.
Get the code

Full script + README on GitHub: ruby-devops-toolkit/cert-expiry-monitor

architecture

How it fits together

Diagram: target queue feeds a thread pool, each thread performs a TLS handshake and reads the peer certificate, results are classified and aggregated into a worst-status exit code

Concurrent handshake -> classify -> aggregate flow
prerequisites

Prerequisites

What you need
  • Ruby 3.0 or newer (tested on 3.0.2). Anything from Ruby 2.5+ should work unmodified.
  • No gems. Everything used — openssl, socket, optparse,
    json, timeout, time — ships in the Ruby standard library.
  • Network access from wherever you run it to the host:port targets on port 443
    (or whatever port you point it at).
  • Platform: Linux, macOS, or Windows — nothing here is OS-specific.
reference

The full script

This is the complete, tested script — the same one in the interactive widget above, for easy copy/paste and reference while you read the walkthrough.

cert_expiry_monitor.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cert_expiry_monitor.rb
#
# Pure Ruby TLS certificate expiry monitor. Connects to a list of
# host[:port] targets, pulls the live leaf certificate presented during
# the TLS handshake (NOT a cached/local copy), and reports how many days
# remain until it expires. No gems required -- just openssl and socket
# from the Ruby standard library.
#
# Exit codes (cron/monitoring friendly):
#   0 -> everything OK
#   1 -> at least one WARN (approaching expiry)
#   2 -> at least one CRIT (expired, about to expire, or unreachable)
#
# Usage:
#   ruby cert_expiry_monitor.rb HOST[:PORT] [HOST[:PORT] ...] [options]
#
# Options:
#   --warn-days N     Days-remaining threshold for WARN   (default: 30)
#   --crit-days N     Days-remaining threshold for CRIT   (default: 7)
#   --timeout N       Per-connection timeout in seconds   (default: 5)
#   --json            Emit machine-readable JSON instead of text
#
require 'openssl'
require 'socket'
require 'optparse'
require 'json'
require 'timeout'
require 'time'
# ---------------------------------------------------------------------------
# CertCheck: connects to a single host:port, performs a TLS handshake, and
# extracts expiry info from the certificate the server actually presents.
# ---------------------------------------------------------------------------
class CertCheck
  Result = Struct.new(:target, :host, :port, :status, :days_remaining,
                       :not_after, :subject, :issuer, :error, keyword_init: true)
  def initialize(target, timeout: 5)
    @target = target
    @host, port_str = target.split(':', 2)
    @port = (port_str || 443).to_i
    @timeout = timeout
  end
  # Opens a raw TCP socket, wraps it in an SSLSocket with SNI set (so
  # name-based virtual hosts serve the right cert), completes the
  # handshake, and reads back the peer certificate. Everything is
  # wrapped in Timeout so a single hung host can't stall the whole run.
  def call
    Timeout.timeout(@timeout) do
      tcp = TCPSocket.new(@host, @port)
      begin
        ctx = OpenSSL::SSL::SSLContext.new
        # We want the cert even if it's untrusted/self-signed/expired --
        # that's exactly the case this tool needs to detect and report,
        # not silently reject.
        ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
        ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
        ssl.hostname = @host # SNI
        ssl.connect
        cert = ssl.peer_cert
        ssl.sysclose
        return build_result(cert)
      ensure
        tcp.close unless tcp.closed?
      end
    end
  rescue StandardError, Timeout::Error => e
    Result.new(target: @target, host: @host, port: @port, status: 'CRIT',
               days_remaining: nil, not_after: nil, subject: nil, issuer: nil,
               error: "#{e.class}: #{e.message}")
  end
  private
  def build_result(cert)
    if cert.nil?
      return Result.new(target: @target, host: @host, port: @port, status: 'CRIT',
                         days_remaining: nil, not_after: nil, subject: nil, issuer: nil,
                         error: 'server presented no certificate')
    end
    days_remaining = ((cert.not_after - Time.now) / 86_400).floor
    Result.new(
      target: @target, host: @host, port: @port,
      status: nil, # classified by caller against thresholds
      days_remaining: days_remaining,
      not_after: cert.not_after.utc.iso8601,
      subject: cert.subject.to_a.find { |name, _, _| name == 'CN' }&.at(1) || cert.subject.to_s,
      issuer: cert.issuer.to_a.find { |name, _, _| name == 'CN' }&.at(1) || cert.issuer.to_s,
      error: nil
    )
  end
end
# ---------------------------------------------------------------------------
# Runner: fans checks out across a small thread pool (TLS handshakes are
# I/O-bound, so threads are a good fit and keep the total wall-clock time
# close to that of the single slowest host, not the sum of all hosts).
# ---------------------------------------------------------------------------
class Runner
  def initialize(targets, warn_days:, crit_days:, timeout:, concurrency: 8)
    @targets = targets
    @warn_days = warn_days
    @crit_days = crit_days
    @timeout = timeout
    @concurrency = concurrency
  end
  def run
    queue = Queue.new
    @targets.each { |t| queue << t }
    results = Queue.new
    workers = Array.new([@concurrency, @targets.size].min) do
      Thread.new do
        until queue.empty?
          target = begin
            queue.pop(true)
          rescue ThreadError
            nil
          end
          next unless target
          result = CertCheck.new(target, timeout: @timeout).call
          classify!(result)
          results << result
        end
      end
    end
    workers.each(&:join)
    drained = []
    drained << results.pop until results.empty?
    # Preserve the order the targets were given on the command line.
    @targets.map { |t| drained.find { |r| r.target == t } }
  end
  private
  def classify!(result)
    return if result.status == 'CRIT' && result.error # unreachable/handshake failure
    if result.days_remaining.nil?
      result.status = 'CRIT'
    elsif result.days_remaining <= @crit_days
      result.status = 'CRIT'
    elsif result.days_remaining <= @warn_days
      result.status = 'WARN'
    else
      result.status = 'OK'
    end
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  options = { warn_days: 30, crit_days: 7, timeout: 5, json: false }
  parser = OptionParser.new do |opts|
    opts.banner = 'Usage: ruby cert_expiry_monitor.rb HOST[:PORT] [HOST[:PORT] ...] [options]'
    opts.on('--warn-days N', Integer, 'Days-remaining threshold for WARN (default: 30)') { |v| options[:warn_days] = v }
    opts.on('--crit-days N', Integer, 'Days-remaining threshold for CRIT (default: 7)') { |v| options[:crit_days] = v }
    opts.on('--timeout N', Integer, 'Per-connection timeout in seconds (default: 5)') { |v| options[:timeout] = v }
    opts.on('--json', 'Emit machine-readable JSON') { options[:json] = true }
    opts.on('-h', '--help', 'Show this help') do
      puts opts
      exit 0
    end
  end
  targets = parser.parse(ARGV)
  if targets.empty?
    warn parser
    exit 2
  end
  results = Runner.new(targets, warn_days: options[:warn_days],
                                 crit_days: options[:crit_days],
                                 timeout: options[:timeout]).run
  if options[:json]
    puts JSON.pretty_generate(results.map(&:to_h))
  else
    printf("%-32s %-6s %-8s %-22s %s\n", 'TARGET', 'STATUS', 'DAYS', 'EXPIRES (UTC)', 'SUBJECT / ERROR')
    puts '-' * 100
    results.each do |r|
      detail = r.error || r.subject
      printf("%-32s %-6s %-8s %-22s %s\n", r.target, r.status, r.days_remaining.nil? ? '-' : r.days_remaining,
             r.not_after || '-', detail)
    end
  end
  worst = results.map(&:status).max_by { |s| { 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[s] }
  exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[worst] || 2)
end
walkthrough

Step-by-step walkthrough

1. CertCheck — one host, one handshake

Each target gets its own CertCheck instance. It opens a raw TCPSocket, wraps it in an OpenSSL::SSL::SSLSocket, and critically sets ssl.hostname = @host before calling connect — that turns on SNI (Server Name Indication), so a server hosting multiple certificates on one IP (nearly every server behind a CDN or load balancer today) presents the right certificate for that hostname rather than a default/fallback one. Skip this and you’ll silently monitor the wrong certificate.

The whole handshake is wrapped in Timeout.timeout, and every exception — DNS failure, connection refused, handshake failure, timeout — is caught and turned into a CRIT result with the error message attached, rather than crashing the whole run over one bad host.

2. Reading the certificate that matters

ssl.peer_cert returns an OpenSSL::X509::Certificate for whatever the server actually sent during the handshake that just completed — live, not cached. From there, cert.not_after is the certificate’s expiry timestamp, and subtracting Time.now and dividing by 86,400 gives whole days remaining.

3. Runner — a small thread pool over a shared queue

Rather than spawning one thread per target (which gets unruly with a long list of hosts), targets are pushed onto a Queue and a fixed-size pool of worker threads (default 8, capped at the number of targets) pulls from it until it’s empty. This bounds concurrency predictably regardless of how many hosts you pass in.

4. Classification and exit codes

classify! compares days_remaining against the two thresholds and assigns OK, WARN, or CRIT. Unreachable hosts are always CRIT — an unreachable endpoint is at least as urgent as an expiring certificate. The process exit code is the worst status across every target, mapped to 0/1/2, which is the convention most monitoring systems (Nagios, cron + alerting wrappers, CI gates) expect.

output

Example output

sandbox test run
$ ruby test_harness.rb
TARGET STATUS DAYS EXPIRES (UTC) SUBJECT / ERROR
127.0.0.1:9101 OK 59 2026-10-03T17:23:49Z localhost
127.0.0.1:9102 WARN 14 2026-08-19T17:23:49Z localhost
127.0.0.1:9103 CRIT 2 2026-08-07T17:23:49Z localhost
127.0.0.1:9199 CRIT – – Errno::ECONNREFUSED
exit code: 2

This was captured against three local OpenSSL::SSL::SSLServer instances (see test_harness.rb in the GitHub folder) presenting self-signed certificates with 60-, 15-, and 3-day expiry windows respectively, plus one closed port to exercise the unreachable-host path. Real usage looks identical, just pointed at real hosts: ruby cert_expiry_monitor.rb example.com api.example.com:8443 --warn-days 21.

troubleshooting

Troubleshooting

Common issues
  • Every target comes back CRIT with an OpenSSL::SSL::SSLError: the target probably isn’t
    speaking TLS on that port at all (e.g. you pointed it at a plaintext HTTP port). Double-check the port.
  • Errno::ECONNREFUSED against a host you know is up: a firewall or security group is very
    likely blocking the port from wherever this script is running — test with nc -zv host 443 first.
  • Results seem to hang for a long time: lower --timeout; the default 5s per host is fine
    for most networks, but a host that black-holes traffic (rather than actively refusing) will eat the full timeout.
  • A wildcard or SAN-only certificate shows an unexpected subject: the script reports the
    certificate’s CN (or full subject string if there’s no CN), which is normal for certs that rely purely on Subject
    Alternative Names — it doesn’t affect the expiry check itself.
  • This was verified with local self-signed test certificates, not a scan of real production domains
    if you’re pointing it at internal/internet-facing infrastructure, run it from wherever your real monitoring already has
    network access, and start with --warn-days generous enough to avoid alert fatigue while you tune it.
extending

Extending this script

Ideas
  • Alerting integration: pipe --json output into a Slack webhook or PagerDuty Events API call
    when the exit code is non-zero — the JSON already has everything a message template needs.
  • SAN / chain inspection: cert.extensions exposes the Subject Alternative Name extension if
    you want to also flag certs missing a hostname they should cover, and ssl.peer_cert_chain gives the full
    chain if you want to check intermediate expiry too, not just the leaf.
  • Fleet inventory from a file: swap the ARGV targets for a YAML/JSON file of
    {host, port, team, warn_days} so different services can have different thresholds in one run.
  • Persisted history: append each run’s JSON to a rolling log to graph days-remaining over time and catch
    a renewal automation that silently stopped working, not just an already-close deadline.