Guide
Bash script to check SSL certificate expiry
Reusable Bash script to check SSL certificate expiry with openssl, timeouts, and exit codes for cron and CI—not one-off copy-paste.
Updated Apr 10, 2026 845 words
A portable bash script ssl expiry wrapper turns openssl one-liners into cron-safe checks with consistent output, timeouts, and nonzero exits when
notAftercrosses your warning window.
Operators copy openssl recipes from blog posts, then lose them across laptops. Packaging the same logic in a small script makes peer review possible, documents default port 443 and SNI behavior, and lets Nagios-style monitors parse a single line per host.
This guide ships a minimal script pattern, threshold flags, and how to run it from systemd timers—complementary to CSV batching in check SSL expiry for multiple domains from CSV.
Shell remains the lowest-friction option on bastions that already carry openssl for incident response. A bash script ssl expiry wrapper documents default SNI behavior for junior on-call engineers who should not improvise new openssl flags during outages.
Container images often strip man pages but keep bash. Shipping the script beside Helm charts gives SRE a consistent contract: exit code one means warn, two means expired, three means unreachable—regardless of cloud vendor.
What to check
The script should accept hostname, optional port, SNI, and warn-days threshold. It reports certificate expiry (notAfter) from the live handshake, not file mtimes on disk. Trust errors may still print dates; decide whether expired certs return exit code 2 while unreachable hosts return code 3.
Require timeout around openssl to avoid hung cron jobs. GNU date vs BSD date differences matter when computing days remaining—document which platforms you support.
Do not conflate script success with renewal completion; after deploy, still verify all load balancer nodes. For deep openssl field reading, align with OpenSSL check certificate expiry date.
Operations teams practicing bash script ssl expiry hygiene often wire the same wrapper into three places: interactive bastions, cron on a utilities server, and GitHub Actions on default branch. Each context uses different secrets and host lists, but the stdout format stays identical so grep recipes and PagerDuty parsers do not fork. Document which host list file is canonical for each context to avoid debating numbers during bridges.
Change review boards should see the script diff when exit codes or warning defaults change, even if the diff looks boring. Minor threshold tweaks have paged entire enterprises when marketing domains share renewal timing with transactional APIs nobody remembered were in hosts.txt.
Step-by-step
Save as check-ssl-expiry.sh, chmod +x.
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 [-w DAYS] [-p PORT] [-s SNI] HOST" >&2
exit 1
}
WARN_DAYS=30
PORT=443
SNI=""
HOST=""
while getopts ":w:p:s:" opt; do
case "$opt" in
w) WARN_DAYS="$OPTARG" ;;
p) PORT="$OPTARG" ;;
s) SNI="$OPTARG" ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
HOST="${1:-}"; [ -n "$HOST" ] || usage
SNI="${SNI:-$HOST}"
enddate=$(timeout 15 bash -c \
"echo | openssl s_client -connect '${HOST}:${PORT}' -servername '${SNI}' 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null") || {
echo "UNREACHABLE ${HOST}:${PORT} sni=${SNI}"
exit 3
}
not_after="${enddate#notAfter=}"
echo "OK ${HOST}:${PORT} sni=${SNI} expires=${not_after}"
if date --version >/dev/null 2>&1; then
end_epoch=$(date -d "$not_after" +%s)
now_epoch=$(date +%s)
else
end_epoch=$(date -j -f "%b %d %H:%M:%S %Y %Z" "$not_after" +%s)
now_epoch=$(date +%s)
fi
remaining=$(( (end_epoch - now_epoch) / 86400 ))
if [ "$remaining" -lt 0 ]; then
echo "EXPIRED ${remaining} days ago"
exit 2
elif [ "$remaining" -lt "$WARN_DAYS" ]; then
echo "WARN ${remaining} days left (threshold ${WARN_DAYS})"
exit 1
fi
exit 0
Run manually
./check-ssl-expiry.sh -w 14 -s www.example.com www.example.com
Cron example (daily 06:00 UTC)
0 6 * * * /opt/monitoring/check-ssl-expiry.sh -w 30 www.example.com || logger -t ssl-expiry "www.example.com check failed"
Loop a hostfile
while read -r h; do
[ -z "$h" ] && continue
./check-ssl-expiry.sh -w 30 "$h" || true
done < /opt/monitoring/hosts.txt
Use || true only for reporting loops; for alerting, drop it and handle exit codes in a wrapper.
CI gate (fail build if <7 days)
./check-ssl-expiry.sh -w 7 api.staging.example.com
Nonzero exit should fail the pipeline when policy demands fresh certs on staging too.
How to read the output
Lines begin with OK, WARN, EXPIRED, or UNREACHABLE. expires= shows openssl’s notAfter string (often GMT). WARN uses your -w threshold, not CA policy. Exit codes: 0 healthy, 1 inside warning window, 2 expired, 3 connection or parse failure.
Pipe script output through logger or journalctl with structured prefixes so Elasticsearch queries can facet on UNREACHABLE without regex gymnastics. Nagios plugins should map exit codes explicitly in check command definitions—default interpretations treat code 1 as UNKNOWN unless you document WARN semantics in operator manuals.
When WARN fires but openssl shows thirty-two days left, verify the script’s date parser branch on your OS. Alpine musl containers sometimes lack GNU date; BSD containers need the date -j path documented in your Dockerfile comments.
Multiple WARN lines in one cron email should sort by ascending days remaining so humans tackle the soonest risk first. Attach full stdout even when only one host failed; neighboring OK lines prove the script ran completely rather than aborting mid-file.
When debugging, run openssl without the script to see verification errors:
openssl s_client -connect HOST:443 -servername SNI </dev/null 2>&1 | head -40
Common mistakes
Hardcoding openssl path on macOS Homebrew versus Linux /usr/bin/openssl breaks cron. Use command -v openssl or absolute paths in systemd units.
Parsing notAfter with the wrong locale fails silently on non-English systems. Prefer epoch conversion via openssl x509 -enddate only, or force LC_ALL=C.
Running as root unnecessarily; the script only needs outbound TCP 443. Least-privilege service accounts reduce blast radius.
Ignoring SNI on multi-tenant IPs makes the script report a neighbor’s expiry date. Always pass -s when connecting by IP.
Copying script to servers inside the DMZ only—external handshakes see different chains. Run from a bastion or external monitor. Compare with PowerShell check SSL certificate expiry on Windows-heavy shops.
When to use automation
Cron plus bash suits small fleets and air-gapped environments. Migrate to centralized monitoring when host count exceeds low hundreds or when you need RDAP and TLS in one alert.
Schedule staggered cron minutes to avoid thundering herds. Log to syslog or JSON lines ingested by Loki. Wrap multiple hosts in a driver that aggregates exit codes and pages once.
For managed SaaS, ssldays-style external checks reduce VPN plumbing while you keep the bash script for pre-deploy validation on the shell server. Pair script output with ticket auto-creation when exit code is 1 or 2.
Sign bash scripts and verify checksums in configuration management before deploy. Unsigned scripts in world-writable directories become tempting targets for silent hostname list changes.
Use flock when cron might overlap long-running batches. Duplicate overlapping runs amplify provider rate limits and duplicate pages.
Include a --dry-run mode that prints the openssl command without executing when debugging SNI issues in production change windows. Operators paste the line into tickets for peer review before running live.
Archive rotated hosts.txt files with ticket references so postmortems can reproduce exact probe lists weeks later.
SRE leadership should treat the bash script as a contract specification: changing exit codes requires a changelog entry and paging policy updates. Informal edits in production cron without review recreated many overnight incidents when a well-meaning engineer flipped warning thresholds without telling on-call.
Edge cases around daylight saving changes rarely affect GMT notAfter strings but sometimes confuse operators reading local timestamps in email cron output. Standardize on UTC in script echo lines even when humans read results in local time zones during business hours.
When organizations mandate immutable infrastructure, bake the script into container images versioned with semver. Running curl bash installers during incidents invites supply chain risk; package the same logic into signed RPM or deb artifacts distributed through normal configuration management channels.
Disaster recovery datacenters deserve identical cron entries with a LOCATION environment variable printed in logs. Failover drills expose forgotten host lists that still probe primary region names only, missing secondary TLS terminators that customers hit during real failures.
Educational value matters for apprenticeships: the script is short enough to read in a lunch break yet encodes real operations lessons about SNI, timeouts, and exit codes. Pair reading the script with guided openssl runs on a deliberately expired lab hostname so new hires connect theory to muscle memory.
Include the script in runbook annexes for major incidents so alternate commanders execute familiar checks when primary engineers are offline. Standard paths like /opt/monitoring/check-ssl-expiry.sh should appear in wiki diagrams reachable from phone browsers.
Measure script runtime monthly and alert when duration doubles, indicating DNS latency regressions or upstream firewall changes rather than certificate problems alone.
Reliability engineering can treat the script as a load generator lite when capacity planning new egress NAT gateways. Knowing each check opens one TLS session helps estimate connection counts before auditors ask for numbers you never measured.
Finance audits sometimes request evidence that monitoring existed before an outage. Archived cron mail spools with script output satisfy that request more cheaply than reconstructing history from absent SaaS trials.
Partner with security awareness training: phishing teams mention that attackers also watch certificate expiry dates. Consistent monitoring reduces surprise windows when criminals time lookalike domains around your renewal confusion.
When migrating datacenters, run the script from both old and new egress paths during the overlap week. Divergent notAfter results reveal split brain certificate deployment before you retire the old firewall rule base.
Finally, encourage engineers to contribute small patches upstream in internal forks such as optional IPv6 flags or JSON output switches. Shared ownership prevents one burned out maintainer from blocking improvements everyone needs before the next holiday freeze.
Nagios and Icinga inherit exit semantics from plugins like this script; publish a mapping table in your monitoring docs so level-one operators know when to escalate WARN versus EXPIRED versus UNREACHABLE without reading bash source.
Treat hosts.txt like production configuration: review changes in pull requests, tag releases when lists change for acquisitions, and snapshot files before bulk edits during automation migrations.
Site reliability champions at regional offices can maintain small overlay lists for local vanity domains while trusting central cron for global brands. Federated lists merge with clear precedence rules documented in README files beside the script.
Observability about observability sounds meta, but timing each cron run and graphing duration catches creeping DNS latency before handshake timeouts flip healthy hosts to UNREACHABLE during unrelated incidents.
When leadership asks why bash persists beside SaaS monitors, answer honestly: air-gapped validation, auditor requests, and break-glass laptops without API keys still need a few kilobytes of shell that works offline.
During certificate authority incidents covered by news media, run the script against representative hosts before all-hands meetings so executives hear numbers from your own probes, not speculative social posts.
Pair script maintenance with openssl version upgrades in change calendars. New openssl builds occasionally change date string formats; a quick staging run after OS patches prevents parser surprises in production cron mail.
Frequently asked questions
Can I check mail STARTTLS?
Use a different openssl invocation (-starttls smtp on 587). Keep mail checks in a separate script so web cron stays simple—see check mail server TLS expiry.
Why does GNU date differ from macOS?
This script branches on date --version. On BSD, install coreutils and call gdate for simpler maintenance across fleets.
Should exit code 1 page on-call?
Policy choice: many teams page on 2 (expired) and ticket on 1 (warning). Document codes in the monitoring wrapper.
Is bash enough for large portfolios?
It works with hostfiles; at scale prefer Python script to monitor SSL expiry for async I/O and structured JSON logs.
See what the server presents right now
Free live certificate check — no signup.