You Don’t Need Budget to Start
The #1 reason certificate outages happen isn’t lack of budget for fancy tools. It’s lack of any monitoring at all. Most organizations go from “no monitoring” to “expensive platform” without exploring the middle ground: free tools that prevent 80% of incidents.
This post outlines a complete $0 certificate outage prevention strategy using only open-source tools, built-in OS utilities, and scripts you can deploy today. No licenses. No procurement. No budget approval needed.
Then we’ll be honest about when this approach stops being enough.
The $0 Strategy: Four Layers
| Layer | Focus | What it gives you |
|---|---|---|
| Layer 1: Discovery | Know what certificates you have | A complete inventory |
| Layer 2: Monitoring | Get alerts before they expire | Advance warning |
| Layer 3: Documentation | Know who owns what | Clear accountability |
| Layer 4: Process | Know what to do when alerts fire | Repeatable response |
- Total cost: $0 (labor only)
- Implementation time: 1-2 days
- Effectiveness: Prevents ~80% of certificate outages
Layer 1: Free Discovery
PowerShell — Internal Network Scan
# discover-certs.ps1 - Scan internal network for TLS certificates
# No tools needed — uses built-in .NET
$ranges = @(
"10.0.1.1-10.0.1.254",
"10.0.2.1-10.0.2.254",
"192.168.1.1-192.168.1.254"
)
$ports = @(443, 8443, 636, 3389, 5986, 993, 995)
$results = @()
function Test-TLSCertificate {
param([string]$Hostname, [int]$Port)
try {
$tcpClient = New-Object System.Net.Sockets.TcpClient
$tcpClient.Connect($Hostname, $Port)
$sslStream = New-Object System.Net.Security.SslStream(
$tcpClient.GetStream(), $false,
{ param($s,$c,$ch,$e) return $true } # Accept all certs for scanning
)
$sslStream.AuthenticateAsClient($Hostname)
$cert = $sslStream.RemoteCertificate
if ($cert) {
$x509 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
return [PSCustomObject]@{
Host = $Hostname
Port = $Port
Subject = $x509.Subject
Issuer = $x509.Issuer
NotAfter = $x509.NotAfter
DaysLeft = ($x509.NotAfter - (Get-Date)).Days
Thumbprint = $x509.Thumbprint
KeySize = $x509.PublicKey.Key.KeySize
}
}
} catch {
# Host doesn't have TLS on this port — skip
} finally {
if ($sslStream) { $sslStream.Dispose() }
if ($tcpClient) { $tcpClient.Dispose() }
}
return $null
}
# Scan all targets
foreach ($ip in (Get-NetworkRange $ranges)) {
foreach ($port in $ports) {
$result = Test-TLSCertificate -Hostname $ip -Port $port
if ($result) {
$results += $result
}
}
}
# Export results
$results | Export-Csv -Path "certificate-inventory.csv" -NoTypeInformation
$results | Where-Object { $_.DaysLeft -lt 30 } |
Format-Table Host, Port, Subject, DaysLeft -AutoSize
Bash — External Certificate Scan
#!/bin/bash
# discover-external-certs.sh - Scan external endpoints
# Requirements: openssl, curl (both pre-installed on most Linux)
DOMAINS=(
"www.company.com"
"api.company.com"
"portal.company.com"
"mail.company.com"
"vpn.company.com"
)
echo "Host,Port,Subject,Issuer,Expiry,DaysLeft" > cert-inventory.csv
for domain in "${DOMAINS[@]}"; do
for port in 443 8443; do
cert_info=$(echo | timeout 5 openssl s_client \
-connect "$domain:$port" -servername "$domain" 2>/dev/null)
if [ $? -eq 0 ] && echo "$cert_info" | grep -q "BEGIN CERTIFICATE"; then
subject=$(echo "$cert_info" | openssl x509 -noout -subject 2>/dev/null | sed 's/subject=//')
issuer=$(echo "$cert_info" | openssl x509 -noout -issuer 2>/dev/null | sed 's/issuer=//')
expiry=$(echo "$cert_info" | openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//')
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
echo "$domain,$port,$subject,$issuer,$expiry,$days_left" >> cert-inventory.csv
fi
done
done
echo "Discovery complete. Results in cert-inventory.csv"
cat cert-inventory.csv | column -t -s,
AD CS — Free Export
# Export all issued certificates from AD CS (built-in)
certutil -view -restrict "Disposition=20,NotAfter>$(Get-Date -Format 'MM/dd/yyyy')" `
-out "RequestID,CommonName,NotAfter,CertificateTemplate,RequesterName" `
> adcs-active-certs.csv
Scripts vs. CLM Platform: Capability Fit
Score by operational factor (higher = better fit for the approach)
<200 certs
Scripts can work
200-500
Gray zone
500+
CLM required
Layer 2: Free Monitoring
Option A: PowerShell Scheduled Task (Windows)
# cert-monitor.ps1 - Run daily via Task Scheduler
# Checks all known certificates and emails alerts
$AlertThresholdDays = 30
$EmailTo = "it-team@company.com"
$EmailFrom = "cert-monitor@company.com"
$SmtpServer = "smtp.company.com"
# Load inventory (from discovery)
$inventory = Import-Csv "C:\Scripts\cert-inventory.csv"
$alerts = @()
foreach ($cert in $inventory) {
$result = Test-TLSCertificate -Hostname $cert.Host -Port $cert.Port
if ($result -and $result.DaysLeft -lt $AlertThresholdDays) {
$alerts += $result
}
}
if ($alerts.Count -gt 0) {
$body = "<h2>⚠️ Certificate Expiry Alerts</h2>"
$body += "<p>The following certificates expire within $AlertThresholdDays days:</p>"
$body += $alerts | ConvertTo-Html -Fragment
$body += "<p>Action required: Renew these certificates before expiry.</p>"
Send-MailMessage -From $EmailFrom -To $EmailTo `
-Subject "Certificate Alert: $($alerts.Count) certificates expiring soon" `
-Body $body -BodyAsHtml -SmtpServer $SmtpServer
}
# Log results
$alerts | Export-Csv -Append "C:\Scripts\cert-alert-history.csv" -NoTypeInformation
Schedule it:
# Create scheduled task to run daily at 7 AM
$trigger = New-ScheduledTaskTrigger -Daily -At 7AM
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-File C:\Scripts\cert-monitor.ps1"
Register-ScheduledTask -TaskName "CertificateMonitor" `
-Trigger $trigger -Action $action -User "SYSTEM"
Option B: Cron + Bash (Linux)
#!/bin/bash
# /opt/scripts/cert-monitor.sh - Run daily via cron
THRESHOLD=30
ALERT_EMAIL="it-team@company.com"
INVENTORY="/opt/scripts/endpoints.txt"
ALERT_FILE="/tmp/cert-alerts.txt"
> "$ALERT_FILE"
while IFS=, read -r host port description; do
expiry=$(echo | timeout 5 openssl s_client -connect "$host:$port" \
-servername "$host" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$expiry" ]; then
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if [ $days_left -lt 0 ]; then
echo "EXPIRED ($days_left days): $host:$port - $description" >> "$ALERT_FILE"
elif [ $days_left -lt $THRESHOLD ]; then
echo "EXPIRING ($days_left days): $host:$port - $description" >> "$ALERT_FILE"
fi
else
echo "UNREACHABLE: $host:$port - $description" >> "$ALERT_FILE"
fi
done < "$INVENTORY"
# Send alert if any findings
if [ -s "$ALERT_FILE" ]; then
mail -s "Certificate Expiry Alert - $(date +%Y-%m-%d)" \
"$ALERT_EMAIL" < "$ALERT_FILE"
fi
Cron schedule:
# /etc/cron.d/cert-monitor
# Run every day at 6:00 AM
0 6 * * * root /opt/scripts/cert-monitor.sh
Option C: Prometheus + Blackbox Exporter (Free, Scalable)
# /etc/prometheus/blackbox.yml
modules:
tls_connect:
prober: tcp
timeout: 5s
tcp:
tls: true
# /etc/prometheus/prometheus.yml (add this scrape job)
scrape_configs:
- job_name: 'certificate-expiry'
metrics_path: /probe
params:
module: [tls_connect]
static_configs:
- targets:
- api.company.com:443
- www.company.com:443
- portal.company.com:443
- mail.company.com:443
- vpn.company.com:443
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
Alert rules (free with Prometheus):
# /etc/prometheus/rules/cert-alerts.yml
groups:
- name: certificate_expiry
rules:
- alert: CertExpired
expr: probe_ssl_earliest_cert_expiry - time() < 0
for: 5m
labels:
severity: critical
annotations:
summary: "EXPIRED: {{ $labels.instance }}"
- alert: CertExpiring7d
expr: probe_ssl_earliest_cert_expiry - time() < 604800
for: 1h
labels:
severity: warning
annotations:
summary: "Expiring in {{ $value | humanizeDuration }}: {{ $labels.instance }}"
- alert: CertExpiring30d
expr: probe_ssl_earliest_cert_expiry - time() < 2592000
for: 4h
labels:
severity: info
annotations:
summary: "Expiring in {{ $value | humanizeDuration }}: {{ $labels.instance }}"
Layer 3: Free Documentation
Simple Inventory Template
Track two tables — one for public/production certificates, one for high-priority internal certificates. Note the last-updated date and who maintains it.
Production Certificates
| # | Host | Port | CN/SAN | CA | Expiry | Owner | Auto-Renew |
|---|---|---|---|---|---|---|---|
| 1 | www.company.com | 443 | www.company.com | Let’s Encrypt | 2026-09-15 | DevOps | Yes (certbot) |
| 2 | api.company.com | 443 | api.company.com | DigiCert | 2027-01-20 | Platform | No |
| 3 | mail.company.com | 443 | mail.company.com | DigiCert | 2026-11-03 | IT Ops | No |
| 4 | vpn.company.com | 443 | vpn.company.com | Sectigo | 2026-08-22 | Network | No |
Internal Certificates (High Priority)
| # | Service | Host | Template/CA | Expiry | Owner | Auto-Enroll |
|---|---|---|---|---|---|---|
| 1 | NPS/RADIUS | nps01 | RAS-IAS / AD CS | 2027-03-15 | Network | Yes |
| 2 | Exchange | mail-int | WebServer / AD CS | 2026-12-01 | Messaging | No |
| 3 | SQL Server | sql01 | WebServer / AD CS | 2026-10-15 | DBA | No |
| 4 | LDAPS | dc01-dc03 | DomainController / AD CS | 2027-06-01 | AD Team | Yes |
Ownership Assignment
Rule: Every certificate MUST have an owner. The owner is the person who gets paged when it expires.
If nobody is willing to own it, ask whether it should exist at all.
Layer 4: Free Process
Renewal Runbook
Certificate Renewal Process — when you get an alert (30 days before expiry):
For public certificates (DigiCert, Sectigo):
- Log into CA vendor portal
- Locate certificate by domain name
- Click “Renew” or generate new CSR
- Complete domain validation
- Download new certificate + chain
- Install on target server
- Verify:
curl -v https://[domain] 2>&1 | grep "expire date" - Update inventory spreadsheet
For Let’s Encrypt (certbot):
- SSH to server
- Run:
sudo certbot renew --dry-run - If dry-run succeeds:
sudo certbot renew - Verify:
sudo certbot certificates - If auto-renew is broken, fix cron/systemd timer
For internal (AD CS):
- On the server:
certutil -pulse(force auto-enrollment) - If auto-enrollment fails:
certreq -enroll -machine "[TemplateName]" - Verify:
Get-ChildItem Cert:\LocalMachine\My | Where-Object {$_.Subject -like "*[name]*"} - Restart dependent services if needed
Escalation:
- If renewal fails after 2 attempts, escalate to [senior admin]
- If fewer than 7 days remain, declare an incident and raise an emergency change
- If already expired, trigger immediate all-hands response
When $0 Stops Being Enough
The free approach works well — until it doesn’t. Here’s when you’ve outgrown it:
| Signal | Why It Means You Need More |
|---|---|
| More than 100 certificates | Scripts don’t scale; too many to track manually |
| Multiple people managing certs | Coordination breaks down without central system |
| Compliance audits asking for reports | Spreadsheets aren’t audit evidence |
| Scripts breaking regularly | Maintenance burden exceeds tool cost |
| Discovery finds unknown certs every week | You need continuous, not periodic, discovery |
| Alert fatigue from false positives | Need smart alerting with context |
| Renewals taking more than 30 min each | Need automation, not just alerts |
| Multi-cloud environment | Scripts can’t cover AWS + Azure + on-prem + K8s |
The Progression
| Stage | Approach | What you get |
|---|---|---|
| Stage 1 | Nothing | Any outage is a surprise |
| Stage 2 | Scripts ($0) | You know what’s expiring and react in time |
| Stage 3 | CLM Platform | Automated discovery, alerting, renewal |
| Stage 4 | Full Automation | Zero-touch certificate management |
Most orgs are at Stage 1. This post gets you to Stage 2, which prevents ~80% of outages. Stages 3-4 prevent the remaining 20% and scale beyond what scripts can handle.
Complete $0 Implementation Checklist
Week 1:
- Run discovery scan (choose PowerShell or Bash script above)
- Export AD CS inventory (if applicable)
- Create initial inventory document
- Assign owners to every certificate found
- Identify certificates expiring in under 60 days (handle immediately)
Week 2:
- Deploy monitoring script (daily email alerts)
- Set up scheduled task/cron job
- Test alert delivery (verify emails arrive)
- Write renewal runbook (per cert type)
- Share inventory + runbook with team
Ongoing:
- Run discovery monthly (catch new certs)
- Review alerts daily
- Update inventory when changes occur
- Quarterly: full re-scan and reconcile
The Honest Truth
A $0 strategy prevents most outages. It requires discipline — someone must maintain the scripts, check the alerts, and update the inventory. The moment that discipline slips (vacation, team change, other priorities), you’re back to hoping nothing expires.
That’s the trade-off: $0 in tools, but ongoing human reliability as the single point of failure. A paid platform replaces human reliability with system reliability. Both work. One is just more fragile than the other.
Start with $0. Prove the value. Then use the prevented outages as evidence to justify a platform investment when you’re ready.
About QCecuring
QCecuring is here when you’ve outgrown scripts. Our platform takes everything described in this post — discovery, monitoring, alerting, inventory — and makes it automatic, continuous, and scalable. Start free. Upgrade when your certificate count demands it.
Tags: free certificate monitoring, PowerShell scripts, certificate expiry alerts, open source, Prometheus, cron jobs, certutil, certificate management, outage prevention, zero-budget security