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:
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.
Full script + README on GitHub: ruby-devops-toolkit/cert-expiry-monitor
How it fits together
Prerequisites
- 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:porttargets on port 443
(or whatever port you point it at). - Platform: Linux, macOS, or Windows — nothing here is OS-specific.
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.
#!/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
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.
Example output
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
- 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::ECONNREFUSEDagainst a host you know is up: a firewall or security group is very
likely blocking the port from wherever this script is running — test withnc -zv host 443first.- 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-daysgenerous enough to avoid alert fatigue while you tune it.
Extending this script
- Alerting integration: pipe
--jsonoutput 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.extensionsexposes the Subject Alternative Name extension if
you want to also flag certs missing a hostname they should cover, andssl.peer_cert_chaingives the full
chain if you want to check intermediate expiry too, not just the leaf. - Fleet inventory from a file: swap the
ARGVtargets 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.