{"id":3007,"date":"2026-11-12T14:56:18","date_gmt":"2026-11-12T14:56:18","guid":{"rendered":"https:\/\/hostry.com\/blog\/?p=3007"},"modified":"2026-11-12T14:56:18","modified_gmt":"2026-11-12T14:56:18","slug":"stale-dns-records-after-deleting-vps","status":"publish","type":"post","link":"https:\/\/hostry.com\/blog\/stale-dns-records-after-deleting-vps\/","title":{"rendered":"Stale DNS Records After Deleting a VPS: Find and Remove Them"},"content":{"rendered":"\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"675\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-cover.webp\" alt=\"Stale DNS records after deleting a VPS: a signpost still points to a lot where a different building now stands, and a courier looks on, puzzled\" class=\"wp-image-2989\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-cover.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-cover-300x169.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-cover-1024x576.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-cover-450x253.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-cover-768x432.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<pre class=\"wp-block-preformatted\" style=\"font-size:14px\"><em><strong>Once the IP of a VPS you deleted goes to another customer, plain HTTP to its old subdomain can return their site with status 200. If the server is already gone, list your records, run the audit below and confirm each flag before removing anything. For a server you still run, remove DNS first and delete the server last.<\/strong><\/em><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Stale DNS records point at a server or service you no longer control; the audit below works with any DNS provider, <strong><a href=\"https:\/\/hostry.com\/products\/dns\/\">Hostry free DNS hosting<\/a><\/strong> included.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every output block below was reproduced with NSD 4.8.0, unbound 1.19.2, curl 8.5.0 and OpenSSL 3.0.13 on Ubuntu 24.04.5 LTS, in network namespaces on one machine. Timings reflect Linux defaults there and will differ elsewhere. Names and addresses are documentation examples; put your own zone and IPs in their place.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What a stale A record does after the server is gone<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A DNS record pointing to an old IP keeps resolving as if nothing had changed. Resolvers answer NOERROR with the address, and the failure appears only when a client opens a TCP connection, in a form that depends on what now sits at that IP.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ dig api.example.com A\n;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NOERROR, id: 53350\n;; ANSWER SECTION:\napi.example.com.    230    IN    A    198.51.100.41<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Security write-ups call a stale DNS record a dangling DNS record. The CNAME variant is an alias whose target, such as a hosted docs site, was deleted. For a VPS the usual leftover is an A record, because deleting a server does nothing to a zone hosted elsewhere.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>What sits at the old IP<\/th><th>curl result<\/th><th>Time to fail<\/th><\/tr><\/thead><tbody><tr><td>Nothing, packets dropped, curl defaults<\/td><td>exit 28<\/td><td>134.4 s<\/td><\/tr><tr><td>Nothing, packets dropped, <span class=\"highlighted\">&#8211;connect-timeout 5<\/span><\/td><td>exit 28<\/td><td>5.0 s<\/td><\/tr><tr><td>Closed port or a firewall REJECT<\/td><td>exit 7, connection refused<\/td><td>under 1 ms<\/td><\/tr><tr><td>Another customer&#8217;s server, HTTPS, valid certificate for its own name<\/td><td>exit 60, certificate name mismatch<\/td><td>4 ms<\/td><\/tr><tr><td>The same server, plain HTTP<\/td><td>status 200 with their page<\/td><td>no failure<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">In the first three rows, nothing serves content for your name yet. The last two follow once the provider hands the IP to someone else, who then receives the traffic meant for you, as a 2019 <strong><a href=\"https:\/\/blog.apnic.net\/2019\/01\/09\/be-careful-where-you-point-to-the-dangers-of-stale-dns-records\/\" target=\"_blank\" rel=\"noopener\">APNIC blog post on stale DNS records<\/a><\/strong> describes. Reassignment speed depends on the provider&#8217;s pool, so treat the record as a live risk from the moment the server is deleted.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"800\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-what-happens.webp\" alt=\"What a stale DNS record does after a VPS is deleted: while the IP is unused, connections hang or are refused; once the IP goes to another customer, HTTP returns their site with status 200 and HTTPS fails on the certificate name\" class=\"wp-image-3006\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-what-happens.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-what-happens-300x200.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-what-happens-1024x683.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-what-happens-450x300.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-what-happens-768x512.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That 134.4-second hang in the first row is the kernel retransmitting the SYN: with <span class=\"highlighted\">net.ipv4.tcp_syn_retries<\/span> at its default of 6, the <strong><a href=\"https:\/\/docs.kernel.org\/networking\/ip-sysctl.html\" target=\"_blank\" rel=\"noopener\">kernel&#8217;s ip-sysctl documentation<\/a><\/strong> puts the final timeout at about 131 seconds. Set the <strong><a href=\"https:\/\/curl.se\/docs\/manpage.html#--connect-timeout\" target=\"_blank\" rel=\"noopener\">curl &#8211;connect-timeout option<\/a><\/strong> in health checks that might call an old name, and branch on the exit code: curl printed &#8220;Couldn&#8217;t connect to server&#8221; for exit 7 and for the 134-second exit 28.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Put every record in one list first<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Give the audit a plain text file with one record per line, written as <span class=\"highlighted\">name type value<\/span> with full names. Copy the records from your panel into <span class=\"highlighted\">records.txt<\/span>; subdomains nobody wrote down are the ones missed at teardown, so take the names from the panel itself. Lines starting with # or ; are skipped, and any run of spaces separates the columns. <strong><a href=\"https:\/\/hostry.com\/products\/dns\/\">Hostry free DNS<\/a><\/strong> has no limits on the number of records or queries, so test and staging names can share the production zone and this one list.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># pasted from the DNS panel of example.com\nexample.com          NS     ns1.example.com\nexample.com          A      198.51.100.17\nwww.example.com      A      198.51.100.17\napi.example.com      A      198.51.100.41\nstaging.example.com  A      198.51.100.62\ngrafana.example.com  A      203.0.113.88\ndocs.example.com     CNAME  example-docs.pages.example.net\nuptime.example.com   CNAME  uptime-page.example.net\nexample.com          MX     10 mail.example.com\nexample.com          MX     20 mx-old.example.com\nmail.example.com     A      198.51.100.17\nmx-old.example.com   A      198.51.100.62\nexample.com          TXT    \"v=spf1 mx a:mail.example.com ip4:198.51.100.17 ip4:198.51.100.40\/29 ip6:2001:db8:41::\/48 include:_spf.example.net -all\"\n_sip._tcp.example.com SRV   10 5 5060 api.example.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">MX and SRV lines keep priority, weight and port before the target; the script takes the last field as the host. TXT values keep their quotes. A long SPF record split into two quoted strings is joined before checking.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A BIND-style zone export is not read as is, because its TTL or IN column lands in the type field. This awk line cuts each record down to the three fields and writes @ and relative names out in full; put your domain after <span class=\"highlighted\">-v o=<\/span>. Records that span several lines in parentheses are not joined, so check those by hand.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>awk -v o=example.com. '$1==\"$ORIGIN\"{o=$2;next} $1~\/^[;$]\/||NF&lt;2{next} {sub(\/[ \\t]+;.*\/,\"\"); i=1; if(\/^[^ \\t]\/){n=$1; i=2; if(n==\"@\")n=o; else if(n!~\/\\.$\/)n=n\".\"o} while($i~\/^[0-9]\/||toupper($i)==\"IN\")i++; if($i!~\/^[A-Za-z]\/)next; v=$(i+1); for(k=i+2;k&lt;=NF;k++)v=v\" \"$k; print n, $i, v}' example.com.zone &gt; records.txt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Your second input is the retired list: the IPv4 and IPv6 addresses of every server you have deleted, as far back as billing history or old notes go.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A file saved on Windows needs no conversion, and a last line with no newline after it is read too. In the reproduction, the carriage return at each line end (shown as ^M) made every A record look dead until the script was changed to strip it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>www.example.com A 198.51.100.17^M\n  dns     !! listed 198.51.100.17^M, DNS answers '198.51.100.17' (NOERROR)\n  tcp     !! 22 unreachable, 80 unreachable, 443 unreachable: nothing answers, server gone?<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Find stale DNS records with an 80-line script<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Run <span class=\"highlighted\">audit-dns.sh<\/span> with the record list and the retired addresses as arguments. For each A or AAAA record it compares DNS with the list, tries ports 22, 80 and 443, reads the certificate name and looks up the PTR. CNAME targets are checked for NXDOMAIN and NODATA, and every value is matched against the retired IPs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It needs bash, dig (the bind9-dnsutils package on Ubuntu 24.04), openssl and <span class=\"highlighted\">timeout<\/span> from coreutils; ports are tested through bash&#8217;s own \/dev\/tcp. It only reads: name lookups, one TCP connection per port, one TLS handshake.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo apt install bind9-dnsutils openssl<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It suits a zone of a few dozen records, because checks run one after another. A host that listens only on 25 or 5060 is reported as nothing answering; add its port to the <span class=\"highlighted\">for p in 22 80 443<\/span> loop. The IPv6 connection test is unverified: the reproduction had no IPv6, so every AAAA came back unreachable. The TLS check reads the certificate name and expiry only and skips chain verification.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# audit-dns.sh - find DNS records that still point at servers you deleted.\n# Usage: .\/audit-dns.sh records.txt [retired-ip ...]\n#   records.txt: one record per line, \"name type value\", full names, copied from your DNS panel:\n#     www.example.com A 198.51.100.17\n#     example.com TXT \"v=spf1 ip4:198.51.100.40\/29 -all\"\n#   retired-ip: addresses of servers you deleted; every A, AAAA, MX, SRV, CNAME and TXT value is checked.\n# Env: TIMEOUT (seconds per check, default 3), RESOLVER (e.g. 1.1.1.1; default: system resolver).\n# Read-only: it resolves names, opens TCP connections to the listed IPs and reads TLS certificates.\n# Exit: 0 nothing flagged, 1 something flagged, 2 no arguments, or records file missing, unreadable or without records.\nset -uf    # no globbing: SPF tokens like ?all stay literal\n(( $# )) || { echo \"usage: ${0##*\/} records.txt [retired-ip ...]\" &gt;&amp;2; exit 2; }\nlist=$1; shift; retired=(\"$@\")\n[[ -r $list &amp;&amp; ! -d $list ]] || { echo \"cannot read records file: $list\" &gt;&amp;2; exit 2; }\nT=${TIMEOUT:-3}; at=${RESOLVER:+@$RESOLVER}; flags=0 seen=0\nsay()  { printf '  %-7s %s\\n' \"$1\" \"$2\"; }\nflag() { say \"$1\" \"!! $2\"; flags=$((flags+1)); }\nip4n() { local IFS=.; set -- $1; echo $(( ($1&lt;&lt;24)|($2&lt;&lt;16)|($3&lt;&lt;8)|$4 )); }\nip6x() { local a=${1,,} l r i out=(); l=$a; r=\"\"          # IPv6 -&gt; 32 hex digits\n  [[ $a == *::* ]] &amp;&amp; { l=${a%%::*}; r=${a#*::}; }\n  IFS=: read -ra L &lt;&lt;&lt;\"$l\"; IFS=: read -ra R &lt;&lt;&lt;\"$r\"; out=(\"${L[@]}\")\n  for ((i=${#L[@]}+${#R[@]}; i&lt;8; i++)); do out+=(0); done\n  for i in \"${out[@]}\" \"${R[@]}\"; do printf '%04x' \"0x$i\"; done; }\nin_net() { local ip=$1 net=${2%\/*} len=\"\"; [[ $2 == *\/* ]] &amp;&amp; len=${2#*\/}   # is IP inside NET[\/LEN]?\n  if [[ $ip == *:* &amp;&amp; $net == *:* ]]; then\n    local a b n; a=$(ip6x \"$ip\"); b=$(ip6x \"$net\"); len=${len:-128}; n=$((len\/4))\n    [[ ${a:0:n} == \"${b:0:n}\" ]] || return 1\n    (( len%4 == 0 || (0x${a:n:1} &gt;&gt; (4-len%4)) == (0x${b:n:1} &gt;&gt; (4-len%4)) ))\n  elif [[ $ip != *:* &amp;&amp; $net =~ ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n    len=${len:-32}; local m=$(( len ? (0xFFFFFFFF &lt;&lt; (32-len)) &amp; 0xFFFFFFFF : 0 ))\n    (( ($(ip4n \"$ip\") &amp; m) == ($(ip4n \"$net\") &amp; m) ))\n  else return 1; fi; }\nretired_in() { local r; for r in \"${retired[@]}\"; do in_net \"$r\" \"$2\" &amp;&amp; flag retired \"$1 $2 covers retired IP $r\"; done; }\nq() { local out; out=$(dig $at +noall +comments +answer \"$1\" \"$2\" 2&gt;&amp;1)   # sets $st and $ans\n  st=$(grep -o 'status: [A-Z]*' &lt;&lt;&lt;\"$out\"); st=${st#status: }; st=${st:-NO-REPLY}\n  ans=$(awk -v t=\"$2\" '$4==t {print $5}' &lt;&lt;&lt;\"$out\" | tr '\\n' ' '); ans=${ans% }; }\nport() { local err; err=$(timeout \"$T\" bash -c \": &lt;\/dev\/tcp\/$1\/$2\" 2&gt;&amp;1)\n  case $? in 0) echo open;; 124) echo timeout;; *) [[ $err == *refused* ]] &amp;&amp; echo refused || echo unreachable;; esac; }\ntls() { local hp=$2:443 pem san; [[ $2 == *:* ]] &amp;&amp; hp=\"[$2]:443\"\n  pem=$(timeout \"$T\" openssl s_client -connect \"$hp\" -servername \"$1\" &lt;\/dev\/null 2&gt;\/dev\/null | openssl x509 2&gt;\/dev\/null)\n  [[ -z $pem ]] &amp;&amp; { flag tls \"port 443 is open but no certificate came back\"; return; }\n  san=$(openssl x509 -noout -ext subjectAltName &lt;&lt;&lt;\"$pem\" | tail -n +2 | tr -d ' ')\n  if ! openssl x509 -noout -checkhost \"$1\" &lt;&lt;&lt;\"$pem\" | grep -q 'does match'; then flag tls \"certificate is for ${san:-?}, not $1\"\n  elif ! openssl x509 -noout -checkend 0 &lt;&lt;&lt;\"$pem\" &gt;\/dev\/null; then flag tls \"certificate for $1 has expired\"\n  else say tls \"ok: certificate covers $1\"; fi; }\n\nwhile read -r name type value &lt;&amp;3 || [[ -n $name ]]; do   # || ...: last line without a newline\n  name=${name%$'\\r'}; [[ -z $name || $name == [\\#\\;\\$]* ]] &amp;&amp; continue; seen=$((seen+1))\n  value=${value%$'\\r'}; name=${name%.}; type=${type^^}; value=${value%.}; echo \"$name $type $value\"   # \\r: Windows line ends\n  case $type in\n  A|AAAA) q \"$name\" \"$type\"\n    [[ \" $ans \" == *\" $value \"* ]] &amp;&amp; say dns \"ok: resolves to $value\" || flag dns \"listed $value, DNS answers '${ans:-nothing}' ($st)\"\n    r=\"\"; up=0; for p in 22 80 443; do s=$(port \"$value\" $p); r+=\"$p $s, \"; [[ $s == open ]] &amp;&amp; up=1; done\n    (( up )) &amp;&amp; say tcp \"${r%, }\" || flag tcp \"${r%, }: nothing answers, server gone?\"\n    [[ $r == *\"443 open\"* ]] &amp;&amp; tls \"$name\" \"$value\"\n    ptr=$(dig $at +short -x \"$value\" 2&gt;\/dev\/null | head -1); say ptr \"${ptr:-none}\"\n    retired_in \"$type\" \"$value\";;\n  CNAME) q \"$name\" CNAME; [[ ${ans%.} == \"$value\" ]] || flag dns \"listed $value, DNS answers '${ans:-nothing}' ($st)\"\n    q \"$value\" A; [[ $st == NOERROR &amp;&amp; -z $ans ]] &amp;&amp; q \"$value\" AAAA\n    case $st in\n      NXDOMAIN) flag cname \"target $value does not exist (NXDOMAIN): dangling\";;\n      NOERROR) if [[ -n $ans ]]; then say cname \"ok: target resolves to $ans\"; for a in $ans; do retired_in \"target\" \"$a\"; done\n               else flag cname \"target $value exists but has no A\/AAAA (NOERROR, empty answer)\"; fi;;\n      *) flag cname \"target lookup failed: $st\";; esac;;\n  MX|SRV) tgt=${value##* }; tgt=${tgt%.}; q \"$tgt\" A; a4=$ans; s4=$st; q \"$tgt\" AAAA; ans=$(echo $a4 $ans)\n    [[ -n ${ans\/\/ } ]] &amp;&amp; say \"${type,,}\" \"$tgt -&gt; $ans\" || flag \"${type,,}\" \"target $tgt has no address ($s4)\"\n    for a in $ans; do retired_in \"$type $tgt -&gt;\" \"$a\"; done;;\n  TXT) v=$(sed 's\/\" *\"\/\/g; s\/\"\/\/g' &lt;&lt;&lt;\"$value\"); n=0\n    for tok in $v; do t=${tok#[-+~?]}\n      case ${t,,} in\n        ip4:*|ip6:*) retired_in \"${t%%:*}\" \"${t#*:}\";;\n        include:*|a|a[:\/]*|mx|mx[:\/]*|ptr|ptr:*|exists:*|redirect=*) n=$((n+1));;\n        [0-9]*.*.*.*) retired_in TXT \"$t\";;\n      esac; done\n    [[ $v == v=spf1* ]] &amp;&amp; say spf \"$n DNS-lookup terms at this level (limit 10, nested includes count too)\";;\n  *) say skip \"type $type not checked\";;\n  esac\ndone 3&lt; \"$list\"\n(( seen )) || { echo \"no records read from $list\" &gt;&amp;2; exit 2; }\necho; echo \"$flags item(s) need attention\"; (( flags == 0 ))   # exit 1 if anything was flagged (for cron)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On line 11, <span class=\"highlighted\">set -uf<\/span> stops ?all in an SPF record from expanding as a shell glob.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>chmod +x audit-dns.sh\n.\/audit-dns.sh records.txt 198.51.100.41 198.51.100.62 2001:db8:41::41<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">TIMEOUT (3 seconds per check) and RESOLVER come from the environment. RESOLVER must name a recursive resolver, because an authoritative-only server answers REFUSED for zones it does not serve.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>RESOLVER=1.1.1.1 .\/audit-dns.sh records.txt 198.51.100.41 198.51.100.62 2001:db8:41::41<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Against the sample zone, served by a local NSD, the first command reported 15 findings for 14 records in 10.5 seconds. About 9 of those seconds went to the one address that drops packets: three ports at three seconds each. The 15 are three hosts where nothing answers, two certificates for another name, two CNAME problems and eight retired-IP matches. One match is the AAAA record of api.example.com, which the SRV check found in the zone though records.txt does not list it, so totals depend on the zone too.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[...]\napi.example.com A 198.51.100.41\n  dns     ok: resolves to 198.51.100.41\n  tcp     !! 22 timeout, 80 timeout, 443 timeout: nothing answers, server gone?\n  ptr     api.example.com.\n  retired !! A 198.51.100.41 covers retired IP 198.51.100.41\nstaging.example.com A 198.51.100.62\n  dns     ok: resolves to 198.51.100.62\n  tcp     !! 22 refused, 80 refused, 443 refused: nothing answers, server gone?\n  ptr     none\n  retired !! A 198.51.100.62 covers retired IP 198.51.100.62\ngrafana.example.com A 203.0.113.88\n  dns     ok: resolves to 203.0.113.88\n  tcp     22 open, 80 open, 443 open\n  tls     !! certificate is for DNS:cp.example.net, not grafana.example.com\n  ptr     none\ndocs.example.com CNAME example-docs.pages.example.net\n  cname   !! target example-docs.pages.example.net does not exist (NXDOMAIN): dangling\nuptime.example.com CNAME uptime-page.example.net\n  cname   !! target uptime-page.example.net exists but has no A\/AAAA (NOERROR, empty answer)\n[...]\nexample.com MX 20 mx-old.example.com\n  mx      mx-old.example.com -&gt; 198.51.100.62\n  retired !! MX mx-old.example.com -&gt; 198.51.100.62 covers retired IP 198.51.100.62\nmail.example.com A 198.51.100.17\n  dns     ok: resolves to 198.51.100.17\n  tcp     22 open, 80 open, 443 open\n  tls     !! certificate is for DNS:www.example.com,DNS:example.com, not mail.example.com\n  ptr     www.example.com.\n[...]\nexample.com TXT \"v=spf1 mx a:mail.example.com ip4:198.51.100.17 ip4:198.51.100.40\/29 ip6:2001:db8:41::\/48 include:_spf.example.net -all\"\n  retired !! ip4 198.51.100.40\/29 covers retired IP 198.51.100.41\n  retired !! ip6 2001:db8:41::\/48 covers retired IP 2001:db8:41::41\n  spf     3 DNS-lookup terms at this level (limit 10, nested includes count too)\n[...]\n\n15 item(s) need attention<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Look at grafana.example.com first. Its address was left off the retired list on purpose, playing a server the owner forgot, and the audit flagged it anyway because <strong>the IP now serves a certificate for cp.example.net<\/strong>. Its DNS answer and open ports looked healthy. Without the certificate check, that stale DNS record would have passed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The mail.example.com flag is harmless, because that shared web server returns its default certificate for a name it does not host. The PTR line is informational; the provider controls it. The expiry test (<span class=\"highlighted\">-checkend 0<\/span>) also catches a renewal job that quietly stopped, which matters more as <strong><a href=\"https:\/\/hostry.com\/blog\/lets-encrypt-45-day-certificates\/\">Let&#8217;s Encrypt moves to 45-day certificates<\/a><\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Remove a stale DNS record only after confirming that its host is retired:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Flag<\/th><th>Next step<\/th><\/tr><\/thead><tbody><tr><td><span class=\"highlighted\">retired<\/span>: a value covers a retired IP<\/td><td>Delete the A or AAAA record; for SPF and MX, see the SPF section below.<\/td><\/tr><tr><td><span class=\"highlighted\">tls<\/span>: certificate for another name<\/td><td>Rule out a shared server&#8217;s default certificate (mail.example.com); for a name nobody hosts, like grafana, add its IP to the retired list.<\/td><\/tr><tr><td><span class=\"highlighted\">cname<\/span>: NXDOMAIN or no address<\/td><td>Read the status line (dig checks below), then delete the alias or restore its target.<\/td><\/tr><tr><td><span class=\"highlighted\">tcp<\/span>: nothing answers<\/td><td>Confirm in the provider&#8217;s panel or billing that the server is gone.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Two checks that mislead: dig +short and x509 -checkhost<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><span class=\"highlighted\">dig +short<\/span> prints nothing both for NXDOMAIN and for NODATA (the name exists but has no A record), so it cannot tell a deleted target from one that only lost its address. Pointed at the alias of a dangling CNAME, it prints the target and looks healthy, while the full answer&#8217;s status line says NXDOMAIN:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ dig +short docs.example.com A\nexample-docs.pages.example.net.\n$ dig docs.example.com A | grep status\n;; -&gt;&gt;HEADER&lt;&lt;- opcode: QUERY, status: NXDOMAIN, id: 28988<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><span class=\"highlighted\">openssl x509 -checkhost<\/span> fails in the opposite direction. In OpenSSL 3.0.13 it exits 0 whether or not the name matches, so a script that tests its return code passes every certificate:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ openssl s_client -connect 203.0.113.88:443 -servername grafana.example.com &lt;\/dev\/null 2&gt;\/dev\/null \\\n    | openssl x509 -noout -checkhost grafana.example.com; echo \"x509 exit=$?\"\nHostname grafana.example.com does NOT match certificate\nx509 exit=0\n$ openssl s_client -connect 198.51.100.17:443 -servername www.example.com &lt;\/dev\/null 2&gt;\/dev\/null \\\n    | openssl x509 -noout -checkhost www.example.com; echo \"x509 exit=$?\"\nHostname www.example.com does match certificate\nx509 exit=0<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That is why the script greps for &#8220;does match&#8221;. If you need a result code, use <span class=\"highlighted\">openssl s_client<\/span> with <span class=\"highlighted\">-verify_hostname<\/span>; in the reproduction, with the lab CA trusted, it printed &#8220;Verify return code: 62 (hostname mismatch)&#8221; for grafana and &#8220;0 (ok)&#8221; for www.example.com. Without a locally trusted chain a chain error can take its place, such as code 21 (unable to verify the first certificate). Even on a mismatch, s_client exits 0 unless you add <span class=\"highlighted\">-verify_return_error<\/span>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">SPF and MX: where an old IP address keeps working<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An SPF record with an old IP address keeps authorizing that address after the server is gone, and a stale MX record does the same through the mx mechanism. In the reproduction, the deleted server 198.51.100.41 sat inside <span class=\"highlighted\">ip4:198.51.100.40\/29<\/span>, and both pyspf 2.0.14 and libspf2 1.2.10 returned pass for it. Both tools are in Ubuntu&#8217;s universe repository (<span class=\"highlighted\">sudo apt install python3-spf spfquery<\/span>); spf.py may also print a SyntaxWarning.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ python3 \/usr\/lib\/python3\/dist-packages\/spf.py 198.51.100.41 postmaster@example.com mail.example.com\nresult: ('pass', 250, 'sender SPF authorized') ip4:198.51.100.40\/29\n$ python3 \/usr\/lib\/python3\/dist-packages\/spf.py 198.51.100.62 postmaster@example.com mail.example.com\nresult: ('pass', 250, 'sender SPF authorized') mx\n$ spfquery -ip 198.51.100.41 -sender postmaster@example.com -helo mail.example.com\npass\n[...]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Address 198.51.100.62 appears in no ip4 entry at all. It passes because the MX record for mx-old.example.com still resolves to it and the SPF record starts with mx. Whoever is assigned either address next can send mail that passes SPF for your domain. For each retired address:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>Shrink any ip4: or ip6: range that covers it to the addresses still in use. An entry without a prefix length means \/32 for IPv4 and \/128 for IPv6 (RFC 7208, section 5.6).<\/li><li>A retired mail host loses its MX and A records in the same edit; any a: or mx: term naming a host that no longer exists goes with them.<\/li><li>If a sending service is no longer in use, its include: goes too.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Trimming ip4 or ip6 entries does not help with the 10-lookup limit, which counts only terms that make the receiver query DNS:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\"><p>The following terms cause DNS queries: the &#8220;include&#8221;, &#8220;a&#8221;, &#8220;mx&#8221;, &#8220;ptr&#8221;, and &#8220;exists&#8221; mechanisms, and the &#8220;redirect&#8221; modifier.<\/p><cite><strong><a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc7208#section-4.6.4\" target=\"_blank\" rel=\"noopener\">RFC 7208, section 4.6.4<\/a><\/strong><\/cite><\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Removing a stale mx, a: or include: is what lowers the count. If you send mail over IPv6, put ip6: first. With mx and a: ahead of it, pyspf returned permerror after three void lookups (the RFC limit is two), where libspf2 passed; with ip6: first, pyspf passed too.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decommission order: DNS first, server last<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Remove a server&#8217;s records while you still hold its IP address. Delete the server last. Until then a stale DNS record is harmless, because cached answers still reach your own machine. The DNS records to remove when decommissioning a server are every A and AAAA record with its address, MX and SRV records whose targets resolve to it, SPF entries that cover it and CNAMEs pointing at names it hosted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>1. Lower the TTL to 60 seconds before the DNS change<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do it at least one old TTL ahead. With resolvers that honor the TTL, that caps the stale window after removal at about a minute. In the reproduction, a record at TTL 60 lowered to 10 after unbound had cached it was still served until about t=60 s, as if unchanged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>2. Wait one old TTL<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A record that sat at 7200 needs the lowering at least two hours before removal. In the reproduction, lowered to 10 and then left for more than one old TTL, the record&#8217;s stale window after removal shrank to 6.3 seconds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>3. Remove the records and rerun the audit<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Take out everything listed above, SPF ip4 entries included. Copy the records out of the panel again and rerun audit-dns.sh with the address on the retired list; nothing should be flagged for it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>4. Wait again, then query the resolvers you can reach<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Resolver policy can outlast your TTL. With unbound&#8217;s <span class=\"highlighted\">cache-min-ttl<\/span> at 30, a 10-second record stayed cached for 30 seconds, and with <span class=\"highlighted\">serve-expired<\/span> enabled the expired address went out once more carrying a 30-second TTL. Public resolvers were outside the reproduction, so query the ones you can reach (<span class=\"highlighted\">dig @1.1.1.1 staging.example.com A<\/span>) until they return NXDOMAIN.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Meanwhile, remove the address from firewall allowlists on other servers, and from monitoring checks and webhook targets.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>5. Delete the VPS<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do it once nothing resolves to the address and nothing outside DNS names it. If the server has a successor, such as a new <strong><a href=\"https:\/\/hostry.com\/products\/vps\/\">Hostry VPS<\/a><\/strong>, bring it up first, point the records at it in step 3 instead of removing them, and keep the old one running until the caches drain.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"800\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-timeline.webp\" alt=\"Timeline of five steps to retire a VPS without leaving stale DNS records: lower TTL, wait, remove records, wait again, delete the server\" class=\"wp-image-3000\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-timeline.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-timeline-300x200.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-timeline-1024x683.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-timeline-450x300.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/stale-dns-records-after-deleting-vps-timeline-768x512.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><figcaption>Resolver TTL floors can keep old answers longer than the TTL you set.<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Run the audit on a schedule<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Because the script exits 1 when it flags anything and 0 when the list is clean, cron can run it every week and send mail only when there is a finding. It exits 2, with the reason on stderr, when called without arguments or when the records file is missing, unreadable, a directory, empty or only comments; that is non-zero too, so a typo in a path sends a mail with the error.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cron re-checks the saved records.txt, not the live zone, so copy the list again whenever the zone changes. A record added in the panel after the last copy is never checked. Put audit-dns.sh and records.txt in \/opt\/dns-audit\/ and add the line to your user crontab with <span class=\"highlighted\">crontab -e<\/span> (it has no user field). It assumes the host can already send mail with the <span class=\"highlighted\">mail<\/span> command (from mailutils or bsd-mailx).<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># m h dom mon dow  command\n41 6 * * 1  \/opt\/dns-audit\/audit-dns.sh \/opt\/dns-audit\/records.txt 198.51.100.41 198.51.100.62 2001:db8:41::41 &gt; \/var\/tmp\/dns-audit.txt 2&gt;&amp;1 || mail -s \"DNS audit findings\" admin@example.com &lt; \/var\/tmp\/dns-audit.txt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">New stale DNS records appear whenever a server is deleted without the cleanup, and the weekly run also flags listed records changed in the panel but not in records.txt:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>www.example.com A 198.51.100.18\n  dns     !! listed 198.51.100.18, DNS answers '198.51.100.17' (NOERROR)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">To keep both lists current, note a new server&#8217;s names and addresses on the day you set it up, alongside <strong><a href=\"https:\/\/hostry.com\/blog\/secure-new-linux-vps-first-30-minutes\/\">the first half hour of hardening a new Linux VPS<\/a><\/strong>, and add its IPs to the retired list when you delete it. For the opposite failure, a zone that stops answering, see <strong><a href=\"https:\/\/hostry.com\/blog\/dns-outages-secondary-dns\/\">keeping a zone answering through a DNS provider outage<\/a><\/strong>.<\/p>\n<!-- AddThis Advanced Settings generic via filter on the_content --><!-- AddThis Share Buttons generic via filter on the_content -->","protected":false},"excerpt":{"rendered":"<p>A deleted VPS leaves A, MX and SPF records pointing at an address someone else may get next. Find them with an 80-line bash script, then retire server&#8230;<\/p>\n","protected":false},"author":6,"featured_media":2990,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[20,128,127],"tags":[],"class_list":["post-3007","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-system-admin","category-domain-names","category-security"],"read_time":13,"_links":{"self":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/3007","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/comments?post=3007"}],"version-history":[{"count":1,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/3007\/revisions"}],"predecessor-version":[{"id":3011,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/3007\/revisions\/3011"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/media\/2990"}],"wp:attachment":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/media?parent=3007"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/categories?post=3007"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/tags?post=3007"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}