Engineering

CI/CD Domain Health Checks: Validate WHOIS, DNS and SSL in GitHub Actions

Catch expired domains, wrong DNS targets and TLS certificates about to lapse before they break production — with a GitHub Actions workflow that calls WhoisJSON on every deploy.

August 31, 202611 min readEngineering · CI/CD · GitHub Actions · DNS · SSL

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
CI/CD is not a replacement for monitoring. Run these checks on deploy. Keep continuous monitoring for hijack and drift detection between releases.

What to Check Before a Deploy

CheckEndpointTypical gate
Domain expiryGET /api/v1/whoisFail if fewer than N days untilexpires
Transfer lock presentGET /api/v1/whoisWarn ifclientTransferProhibited missing on production apex
DNS A/AAAA matchGET /api/v1/nslookupFail if live A record not in allowed set
Nameserver delegationGET /api/v1/nslookupWarn if NS set differs from baseline
SSL expiryGET /api/v1/ssl-cert-checkFail if certificate expires within N days
SSL issuer / SAN sanityGET /api/v1/ssl-cert-checkWarn 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.

Repository secretGitHub
Name:  WHOISJSON_API_KEY
Value: your WhoisJSON API token

Use 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.

.github/workflows/domain-health.ymlYAML
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.

scripts/domain-health.mjsNode.js
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.

scripts/domain_health.pyPython
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.

SignalRecommendation
SSL expires in < 7 daysFail — renew before shipping
Domain expires in < 14 daysFail on production; warn on staging
A record mismatchFail if unintentional; skip during planned cutovers
Transfer lock missingWarn unless policy mandates lock on apex
WHOIS 429 rate limitRetry 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 onmain or 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
Planned DNS migrations need a bypass. Temporarily disable A-record assertions or pass expected values through workflow inputs during cutover windows.

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
CI/CD Domain Health

Gate Deploys on Domain, DNS and SSL Posture

Run WHOIS expiry, DNS and certificate checks in GitHub Actions before production traffic hits a misconfigured hostname.

GitHub Actions readyWHOIS + DNS + SSLNode.js and Python1,000 free requests/month