Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.
The Problem
An expired TLS certificate is one of the most preventable outages in production: the cert was valid for 90 days, everyone forgot about it, and it silently ticks down to zero until browsers and API clients start refusing to connect — usually noticed by customers before it’s noticed by anyone on-call. Certificate authorities and reminder emails help, but they land in an inbox nobody watches, and they don’t cover internal services, load balancer vhosts, or certs issued by an internal CA.
cert_watch.rb solves this the way a good monitoring check should: it makes an actual TLS connection to each host, the same way a real client would, reads the certificate that comes back, and reports how many days are left before it expires — with configurable warning and critical thresholds and an exit code designed to be wired straight into cron and existing alerting. It needs nothing beyond Ruby’s standard library: socket and openssl ship with every Ruby install.

Prerequisites
- Ruby 2.7+ — standard library only (
socket,openssl,optparse,json,timeout,time), no gems required. - Outbound network access to the hosts and ports you want to check (typically 443, but any TLS port works — SMTPS, LDAPS, a custom API port, etc).
- Works on Linux, macOS, and Windows equally — the script only uses TCP sockets and OpenSSL bindings, both fully cross-platform in Ruby.
The Complete Script
Save this as cert_watch.rb:
#!/usr/bin/env ruby
# cert_watch.rb
#
# Zero-dependency TLS/SSL certificate expiry monitor. Connects to each host
# in a list, pulls the live certificate presented during the TLS handshake,
# and reports days-until-expiry with configurable warning/critical
# thresholds -- the same kind of check that avoids "the cert expired at
# 2am and took down the API" incidents.
#
# Usage:
# ruby cert_watch.rb --hosts hosts.txt
# ruby cert_watch.rb example.com:443 api.example.com --warn 30 --critical 7
# ruby cert_watch.rb --hosts hosts.txt --json report.json
#
require 'socket'
require 'openssl'
require 'optparse'
require 'json'
require 'timeout'
require 'time'
# ---------------------------------------------------------------------------
# CertificateInspector: opens a TLS connection to host:port and returns the
# peer certificate plus derived facts (days left, issuer, SANs).
# ---------------------------------------------------------------------------
class CertificateInspector
Result = Struct.new(:host, :port, :ok, :error, :subject, :issuer, :not_after,
:days_left, :san, keyword_init: true)
def initialize(timeout: 6)
@timeout = timeout
end
def inspect_host(host, port = 443)
cert = fetch_certificate(host, port)
days_left = ((cert.not_after - Time.now) / 86_400).floor
Result.new(
host: host, port: port, ok: true, error: nil,
subject: cert.subject.to_a.map { |name, val| "#{name}=#{val}" }.join(", "),
issuer: cert.issuer.to_a.find { |n, *_| n == "O" }&.dig(1) || cert.issuer.to_s,
not_after: cert.not_after,
days_left: days_left,
san: extract_san(cert)
)
rescue => e
Result.new(host: host, port: port, ok: false, error: "#{e.class}: #{e.message}",
subject: nil, issuer: nil, not_after: nil, days_left: nil, san: [])
end
private
def fetch_certificate(host, port)
Timeout.timeout(@timeout) do
tcp = TCPSocket.new(host, port)
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # we only need the cert, not full chain trust
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
ssl.hostname = host # enable SNI so name-based vhosts return the right cert
ssl.connect
cert = ssl.peer_cert
ssl.close
tcp.close
cert
end
end
def extract_san(cert)
ext = cert.extensions.find { |e| e.oid == "subjectAltName" }
return [] unless ext
ext.value.split(",").map { |s| s.strip.sub(/^DNS:/, "") }
end
end
# ---------------------------------------------------------------------------
# Report: classifies each result against warn/critical thresholds and
# renders a readable summary.
# ---------------------------------------------------------------------------
class Report
def initialize(results, warn_days:, critical_days:)
@results = results
@warn_days = warn_days
@critical_days = critical_days
end
def severity(result)
return "ERROR" unless result.ok
return "CRITICAL" if result.days_left <= @critical_days
return "WARN" if result.days_left <= @warn_days
"OK"
end
def print_summary
puts "=" * 72
puts "TLS CERTIFICATE EXPIRY REPORT (warn <= #{@warn_days}d, critical <= #{@critical_days}d)"
puts "=" * 72
@results.each do |r|
sev = severity(r)
label = "#{r.host}:#{r.port}".ljust(28)
if r.ok
puts "#{sev.ljust(9)} #{label} expires #{r.not_after.strftime('%Y-%m-%d')} (#{r.days_left}d left) issuer=#{r.issuer}"
else
puts "#{sev.ljust(9)} #{label} #{r.error}"
end
end
puts "-" * 72
counts = @results.group_by { |r| severity(r) }.transform_values(&:size)
puts "Summary: " + %w[OK WARN CRITICAL ERROR].map { |s| "#{s}=#{counts[s] || 0}" }.join(" ")
end
def to_a
@results.map do |r|
{ host: r.host, port: r.port, ok: r.ok, severity: severity(r), days_left: r.days_left,
not_after: r.not_after&.iso8601, issuer: r.issuer, san: r.san, error: r.error }
end
end
def exit_code
return 2 if @results.any? { |r| severity(r) == "CRITICAL" || severity(r) == "ERROR" }
return 1 if @results.any? { |r| severity(r) == "WARN" }
0
end
end
if __FILE__ == $0
options = { warn: 30, critical: 7, timeout: 6 }
OptionParser.new do |o|
o.banner = "Usage: cert_watch.rb [host:port ...] [--hosts FILE] [--warn N] [--critical N] [--json PATH]"
o.on("--hosts FILE", "Newline-delimited list of host or host:port entries") { |v| options[:hosts_file] = v }
o.on("--warn N", Integer, "Warn threshold in days (default 30)") { |v| options[:warn] = v }
o.on("--critical N", Integer, "Critical threshold in days (default 7)") { |v| options[:critical] = v }
o.on("--json PATH", "Write machine-readable report to PATH") { |v| options[:json] = v }
o.on("--timeout N", Integer, "Per-host connection timeout in seconds") { |v| options[:timeout] = v }
end.parse!(ARGV)
targets = ARGV.dup
targets += File.readlines(options[:hosts_file]).map(&:strip).reject { |l| l.empty? || l.start_with?("#") } if options[:hosts_file]
abort "Provide at least one host (as an argument or via --hosts FILE)" if targets.empty?
inspector = CertificateInspector.new(timeout: options[:timeout])
results = targets.map do |t|
host, port = t.split(":")
inspector.inspect_host(host, (port || 443).to_i)
end
report = Report.new(results, warn_days: options[:warn], critical_days: options[:critical])
report.print_summary
if options[:json]
File.write(options[:json], JSON.pretty_generate(report.to_a))
puts "\nJSON report written to #{options[:json]}"
end
exit report.exit_code
end
How It Works, Step by Step
1. Making the same TLS handshake a browser makes
CertificateInspector#fetch_certificate opens a plain TCPSocket, wraps it in an OpenSSL::SSL::SSLSocket, and calls connect — performing a real TLS handshake against the target. Setting ssl.hostname = host before connecting enables SNI (Server Name Indication), which is essential: without it, a server hosting multiple TLS certificates behind one IP (extremely common behind load balancers and CDNs) will return whichever certificate happens to be the default, not necessarily the one you meant to check.
2. Why VERIFY_NONE is correct here, not a shortcut
Setting ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE looks alarming out of context — normally you want strict certificate verification. But this script’s job is different from a browser’s: it isn’t trying to decide whether to trust the connection, it’s trying to read whatever certificate the server presents, including expired ones, self-signed ones, and ones from internal CAs your workstation doesn’t have in its trust store. Disabling verification here means an expired or otherwise “invalid” cert still gets read and reported — which is exactly the case you’re trying to catch.
3. Extracting Subject Alternative Names by hand
Ruby’s OpenSSL::X509::Certificate doesn’t expose SANs as a clean array — they live inside a raw X.509 extension. extract_san finds the extension with OID subjectAltName, then parses its comma-separated value, stripping the DNS: prefix each entry carries. This is a good example of a spot where Ruby’s OpenSSL bindings are a thin, faithful wrapper around the underlying library rather than a friendly abstraction — you occasionally have to reach into the certificate structure directly.
4. Struct-based results keep error handling uniform
Result is a Struct with keyword_init: true, used for both successful and failed lookups (a failed one just has ok: false and every other field nil except error). This means Report never needs a special code path for exceptions — every host produces the same shape of object, and severity can treat “couldn’t connect at all” as its own top-priority category (ERROR) right alongside CRITICAL.
5. An exit code designed for automation
Report#exit_code returns 0/1/2 following the Nagios-style convention many monitoring systems already understand (OK/WARNING/CRITICAL). That means this script drops into an existing cron + alerting pipeline with zero glue code: cron runs it, a wrapper checks $?, and your paging system fires on non-zero exactly like it would for any other check plugin.
Example Run
Checking three hosts with a 30-day warning and 7-day critical threshold (tested locally against TLS servers presenting certificates with 90, 19, and 2 days of validity remaining, to exercise all three severities):
$ cat hosts.txt
api.example.com:443
checkout.example.com:443
internal-vpn.example.com:443
$ ruby cert_watch.rb --hosts hosts.txt --warn 30 --critical 7 --json report.json
========================================================================
TLS CERTIFICATE EXPIRY REPORT (warn <= 30d, critical <= 7d)
========================================================================
OK api.example.com:443 expires 2026-10-05 (89d left) issuer=DigiCert Inc
WARN checkout.example.com:443 expires 2026-07-27 (19d left) issuer=Let's Encrypt
CRITICAL internal-vpn.example.com:443 expires 2026-07-10 (2d left) issuer=Internal CA
------------------------------------------------------------------------
Summary: OK=1 WARN=1 CRITICAL=1 ERROR=0
JSON report written to report.json
$ echo $?
2

Scheduling It
Run it from cron once a day, and let the exit code drive alerting instead of scraping text output:
0 6 * * * /usr/bin/ruby /opt/scripts/cert_watch.rb --hosts /opt/scripts/hosts.txt \
--json /var/log/cert_watch/report.json || /opt/scripts/notify_oncall.sh
On Windows, the equivalent is a scheduled task calling ruby.exe C:\scripts\cert_watch.rb --hosts C:\scripts\hosts.txt --json C:\scripts\report.json, with a follow-up step in your task runner checking %ERRORLEVEL%.
Troubleshooting
SocketError: getaddrinfo— DNS can’t resolve the hostname from wherever the script is running. Confirm withdig/nslookupfrom the same host, and double-check for typos in your hosts file.Errno::ECONNREFUSED— nothing is listening on that port, or a firewall is dropping the connection outright rather than the TLS layer rejecting it. Verify the port number and that the service is actually up.Net::ReadTimeout/ hangs — a firewall is silently dropping packets instead of sending a TCP reset. Lower--timeoutso a single unreachable host doesn’t stall the whole run, and consider running checks in parallel threads for large host lists.- Wrong certificate returned for a multi-tenant host — confirm SNI is actually reaching the server; some very old load balancer configurations ignore SNI and always return a default certificate regardless of what a client requests.
- Days-left looks off by one —
days_leftuses.flooron a fractional day count, so a certificate expiring in “20 days and 4 hours” reports as 20, not 21. This is intentional — when it comes to expiry warnings, rounding down is the safer direction.
Extending the Script
- Parallel checks: wrap
inspect_hostcalls in a small thread pool (Ruby’sThreadplus aQueue) so a list of hundreds of hosts doesn’t take minutes to run serially. - Chain and hostname validation: add a second mode that re-runs the handshake with full verification enabled, to separately flag certificates that are technically valid-but-untrusted (wrong hostname, unknown CA, incomplete chain) rather than just expiring.
- Alerting integrations: post
CRITICAL/WARNrows directly to Slack or PagerDuty viaNet::HTTPinstead of relying solely on the exit code, so the specific failing hosts show up in the alert body. - Historical tracking: append each run’s JSON to a rolling log so you can graph certificate lifetimes over time and catch renewal automation that silently stopped working.


