Introduction
Most CI/CD pipelines validate application code, run tests, scan containers and deploy artifacts. Few validate the domain layer that users actually hit: registration expiry, DNS delegation and TLS certificate lifetime.
A deploy can succeed while the apex domain expires in three days, staging still points at last week’s IP, or production TLS expires before the next release window. These failures are expensive because they surface as customer-facing outages — not as a failed unit test.
This guide adds a lightweight domain health gate to GitHub Actions using three WhoisJSON endpoints: WHOIS for expiry and EPP status, DNS lookup for live A/NS records, and SSL certificate check for certificate validity. It complements continuous domain monitoring with a pre-deploy sanity check you control in code.
Why Run Domain Checks in CI/CD?
Scheduled monitoring catches drift between deploys. CI/CD checks catch misconfiguration at the moment you ship — when DNS cutovers, certificate renewals and registrar changes are most likely to be in flight.
- Fail a release if production DNS no longer resolves to the expected IP range
- Warn when domain expiry drops below a threshold (for example 30 days)
- Block deploys when TLS expires inside the next sprint window
- Verify EPP transfer locks remain enabled on critical apex domains
- Document domain posture in CI logs for audit trails
What to Check Before a Deploy
| Check | Endpoint | Typical gate |
|---|---|---|
| Domain expiry | GET /api/v1/whois | Fail if fewer than N days untilexpires |
| Transfer lock present | GET /api/v1/whois | Warn ifclientTransferProhibited missing on production apex |
| DNS A/AAAA match | GET /api/v1/nslookup | Fail if live A record not in allowed set |
| Nameserver delegation | GET /api/v1/nslookup | Warn if NS set differs from baseline |
| SSL expiry | GET /api/v1/ssl-cert-check | Fail if certificate expires within N days |
| SSL issuer / SAN sanity | GET /api/v1/ssl-cert-check | Warn if issuer or SAN list unexpected |
For field semantics see the WHOIS JSON reference, A record guide and SSL monitoring guide.
Store the API Key in GitHub Secrets
Never commit API keys. Add code WHOISJSON_API_KEY | as a repository or environment secret in GitHub → Settings → Secrets and variables → Actions.
Name: WHOISJSON_API_KEY
Value: your WhoisJSON API tokenUse environment-scoped secrets (production,staging) when the allowed DNS targets differ per environment.
GitHub Actions Workflow
This workflow runs on pull requests and pushes to code main | . It checks one production domain and fails the job when expiry or SSL thresholds are breached.
name: Domain health check
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
domain-health:
runs-on: ubuntu-latest
env:
DOMAIN: example.com
MIN_EXPIRY_DAYS: 30
MIN_SSL_DAYS: 14
EXPECTED_A: 203.0.113.10
steps:
- uses: actions/checkout@v4
- name: Check domain expiry (WHOIS)
run: |
RESP=$(curl -sf "https://whoisjson.com/api/v1/whois?domain=${DOMAIN}" \
-H "Authorization: TOKEN=${{ secrets.WHOISJSON_API_KEY }}")
EXPIRES=$(echo "$RESP" | jq -r '.expires // empty')
if [ -z "$EXPIRES" ]; then
echo "Missing expires field"; exit 1
fi
EXPIRY_EPOCH=$(date -d "$EXPIRES" +%s)
NOW=$(date +%s)
DAYS=$(( (EXPIRY_EPOCH - NOW) / 86400 ))
echo "Domain expires in ${DAYS} days"
if [ "$DAYS" -lt "$MIN_EXPIRY_DAYS" ]; then
echo "Domain expiry below threshold"; exit 1
fi
- name: Check DNS A record
run: |
RESP=$(curl -sf "https://whoisjson.com/api/v1/nslookup?domain=${DOMAIN}" \
-H "Authorization: TOKEN=${{ secrets.WHOISJSON_API_KEY }}")
MATCH=$(echo "$RESP" | jq -r --arg ip "$EXPECTED_A" '.A | index($ip) != null')
if [ "$MATCH" != "true" ]; then
echo "Expected A record not found"; echo "$RESP" | jq '.A'; exit 1
fi
- name: Check SSL certificate expiry
run: |
RESP=$(curl -sf "https://whoisjson.com/api/v1/ssl-cert-check?domain=${DOMAIN}" \
-H "Authorization: TOKEN=${{ secrets.WHOISJSON_API_KEY }}")
VALID_TO=$(echo "$RESP" | jq -r '.valid_to // .details.valid_to // empty')
EXPIRY_EPOCH=$(date -d "$VALID_TO" +%s)
NOW=$(date +%s)
DAYS=$(( (EXPIRY_EPOCH - NOW) / 86400 ))
echo "SSL expires in ${DAYS} days"
if [ "$DAYS" -lt "$MIN_SSL_DAYS" ]; then
echo "SSL expiry below threshold"; exit 1
fi
Reusable Node.js Check Script
For multi-domain portfolios, extract the logic into a script your workflow calls once per domain list.
const API_KEY = process.env.WHOISJSON_API_KEY;
const BASE = "https://whoisjson.com/api/v1";
const headers = { Authorization: `TOKEN=${API_KEY}` };
async function getJson(path, domain) {
const url = new URL(`${BASE}/${path}`);
url.searchParams.set("domain", domain);
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`${path} ${domain}: HTTP ${res.status}`);
return res.json();
}
function daysUntil(iso) {
const t = Date.parse(iso);
if (Number.isNaN(t)) throw new Error(`Invalid date: ${iso}`);
return Math.floor((t - Date.now()) / 86400000);
}
async function checkDomain(domain, rules) {
const errors = [];
const whois = await getJson("whois", domain);
const dns = await getJson("nslookup", domain);
const ssl = await getJson("ssl-cert-check", domain);
if (whois.expires) {
const d = daysUntil(whois.expires);
if (d < rules.minExpiryDays) errors.push(`expiry ${d}d < ${rules.minExpiryDays}d`);
}
if (rules.expectedA?.length) {
const a = dns.A || [];
const ok = rules.expectedA.some((ip) => a.includes(ip));
if (!ok) errors.push(`A record mismatch: ${a.join(", ") || "none"}`);
}
const validTo = ssl.valid_to || ssl.details?.valid_to;
if (validTo) {
const d = daysUntil(validTo);
if (d < rules.minSslDays) errors.push(`SSL ${d}d < ${rules.minSslDays}d`);
}
return { domain, ok: errors.length === 0, errors };
}
const rules = {
minExpiryDays: Number(process.env.MIN_EXPIRY_DAYS || 30),
minSslDays: Number(process.env.MIN_SSL_DAYS || 14),
expectedA: (process.env.EXPECTED_A || "").split(",").filter(Boolean),
};
const domains = (process.env.DOMAINS || "example.com").split(",");
const results = await Promise.all(domains.map((d) => checkDomain(d.trim(), rules)));
for (const r of results) {
console.log(JSON.stringify(r));
if (!r.ok) process.exitCode = 1;
}
Python Alternative
Same logic withrequests— useful if your pipeline already runs Python tooling.
import os
import sys
from datetime import datetime, timezone
import requests
API_KEY = os.environ["WHOISJSON_API_KEY"]
HEADERS = {"Authorization": f"TOKEN={API_KEY}"}
BASE = "https://whoisjson.com/api/v1"
def get_json(path, domain):
r = requests.get(
f"{BASE}/{path}",
params={"domain": domain},
headers=HEADERS,
timeout=30,
)
r.raise_for_status()
return r.json()
def days_until(iso):
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (dt - datetime.now(timezone.utc)).days
def check_domain(domain, rules):
errors = []
whois = get_json("whois", domain)
dns = get_json("nslookup", domain)
ssl = get_json("ssl-cert-check", domain)
if whois.get("expires"):
d = days_until(whois["expires"])
if d < rules["min_expiry_days"]:
errors.append(f"expiry {d}d below threshold")
expected = rules.get("expected_a") or []
if expected:
a_records = dns.get("A") or []
if not any(ip in a_records for ip in expected):
errors.append(f"A mismatch: {a_records}")
valid_to = ssl.get("valid_to") or (ssl.get("details") or {}).get("valid_to")
if valid_to:
d = days_until(valid_to)
if d < rules["min_ssl_days"]:
errors.append(f"SSL {d}d below threshold")
return {"domain": domain, "ok": not errors, "errors": errors}
rules = {
"min_expiry_days": int(os.getenv("MIN_EXPIRY_DAYS", "30")),
"min_ssl_days": int(os.getenv("MIN_SSL_DAYS", "14")),
"expected_a": [x for x in os.getenv("EXPECTED_A", "").split(",") if x],
}
domains = [d.strip() for d in os.getenv("DOMAINS", "example.com").split(",")]
failed = False
for domain in domains:
result = check_domain(domain, rules)
print(result)
if not result["ok"]:
failed = True
sys.exit(1 if failed else 0)
Fail vs Warn: Design Your Gates
Not every signal should block a deploy. Use hard failures for conditions that will break users imminently; use warnings (non-zero exit 0 or GitHub code ::warning:: | ) for hygiene issues.
| Signal | Recommendation |
|---|---|
| SSL expires in < 7 days | Fail — renew before shipping |
| Domain expires in < 14 days | Fail on production; warn on staging |
| A record mismatch | Fail if unintentional; skip during planned cutovers |
| Transfer lock missing | Warn unless policy mandates lock on apex |
| WHOIS 429 rate limit | Retry with backoff — see rate limits guide |
Limits and Best Practices
- Three API calls per domain per workflow run — budget quota on active monorepos
- Skip WHOIS on every PR build for dev domains; run full checks only on
mainor release tags - Cache results for 15–60 minutes if multiple jobs check the same domain
- Use separate baselines per environment — staging IPs differ from production
- Pair CI gates with hijack detection for control-plane drift between deploys
Frequently Asked Questions
Can I run domain health checks in GitHub Actions?
Yes. Store your WhoisJSON API key as a GitHub secret, call /whois, /nslookup and /ssl-cert-check with curl or a script, and fail the job when thresholds are breached.
Which checks should run on every deploy?
At minimum: SSL expiry, live DNS A/AAAA for the hostname you serve, and domain registration expiry for the apex. Add transfer-lock checks for production brand domains.
How many API calls does one check use?
Three calls per domain — one WHOIS, one DNS lookup, one SSL check. A ten-domain matrix uses 30 calls per workflow run.
Should CI replace domain monitoring?
No. CI validates at deploy time. Continuous monitoring alerts on registrar, DNS and SSL changes between releases.
What if the API returns HTTP 429?
Retry with exponential backoff and respect the Remaining-Requests header. Reduce parallel jobs or cache results within the workflow.
Conclusion
Domain health belongs in CI/CD: expiry, DNS and TLS are part of production readiness. A small GitHub Actions workflow — three API calls per domain — catches misconfiguration before users do. Keep monitoring running between deploys for hijack and drift scenarios CI alone will miss.
Add domain checks to your pipeline
Query WHOIS, DNS and SSL in JSON from GitHub Actions or any CI runner — 1,000 free requests/month to start.
View DocumentationGet API Key