<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pro Gineous</title>
    <description>The latest articles on DEV Community by Pro Gineous (@progineous).</description>
    <link>https://dev.to/progineous</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185815%2Fbf56a66c-225e-4472-b46d-ce669c2bad76.png</url>
      <title>DEV Community: Pro Gineous</title>
      <link>https://dev.to/progineous</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/progineous"/>
    <language>en</language>
    <item>
      <title>Why Your SPF Record Fails With Too Many DNS Lookups, and How to Get Back Under 10</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Sat, 31 Oct 2026 09:05:02 +0000</pubDate>
      <link>https://dev.to/progineous/why-your-spf-record-fails-with-too-many-dns-lookups-and-how-to-get-back-under-10-2g2c</link>
      <guid>https://dev.to/progineous/why-your-spf-record-fails-with-too-many-dns-lookups-and-how-to-get-back-under-10-2g2c</guid>
      <description>&lt;p&gt;Your SPF record can look perfect, list every service that sends mail for you, and still fail on every single message. If a DMARC report or a bounce shows &lt;code&gt;spf=permerror&lt;/code&gt;, the usual reason is that the record needs more than 10 DNS lookups to evaluate, and receivers stop counting at 10.&lt;/p&gt;

&lt;p&gt;The limit comes from RFC 7208, the SPF standard. It exists so that checking one email can't turn into hundreds of DNS queries. The catch is that you don't only pay for the lookups in your own record. Every include pulls in the vendor's record, and that record can have includes of its own. Here's how to count what yours really costs and how to trim it without breaking mail from the services you still use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the error shows up
&lt;/h2&gt;

&lt;p&gt;SPF too many DNS lookups is a quiet failure. Mail doesn't bounce on the spot. Instead you see some of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;spf=permerror&lt;/code&gt; in the Authentication-Results header of a message you sent to an outside mailbox&lt;/li&gt;
&lt;li&gt;DMARC aggregate reports where SPF fails for your own mail servers, even though their IPs are clearly listed&lt;/li&gt;
&lt;li&gt;An online checker saying "too many DNS lookups" or "PermError"&lt;/li&gt;
&lt;li&gt;Mail that started landing in spam right after you added a new tool to the record&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A permerror isn't a pass, so as far as DMARC is concerned SPF failed. If DKIM still passes and is aligned, DMARC passes anyway, which is why the problem can sit there for months. Once you tighten DMARC, or DKIM breaks for one sender, those messages start getting rejected. If you're not sure how to read those reports, start with our guide on &lt;a href="https://blog.progineous.com/read-dmarc-reports/" rel="noopener noreferrer"&gt;reading DMARC reports&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts toward the 10
&lt;/h2&gt;

&lt;p&gt;Only terms that make the receiver ask DNS something count. That's &lt;code&gt;include&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;mx&lt;/code&gt;, &lt;code&gt;ptr&lt;/code&gt;, &lt;code&gt;exists&lt;/code&gt; and the &lt;code&gt;redirect&lt;/code&gt; modifier. Each costs one lookup, and every one of those inside an included record costs one too.&lt;/p&gt;

&lt;p&gt;These are free: &lt;code&gt;ip4&lt;/code&gt;, &lt;code&gt;ip6&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt;. You can list fifty IP ranges and pay nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh42fa5yb4q12i1xd9eq3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh42fa5yb4q12i1xd9eq3.png" alt="Diagram of what counts toward the SPF limit of 10: include, a, mx, ptr, exists and redirect each cost one DNS lookup, while ip4, ip6, all and exp are free, with an example record v=spf1 mx include:spf.example.net ip4:192.0.2.10 ~all that costs 4 lookups because the include pulls in two more includes" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two more rules sit next to the main one. An &lt;code&gt;mx&lt;/code&gt; term can't point at a name with more than 10 MX hosts. And there's a limit of two "void" lookups, meaning queries that come back with no answer at all. A typo in an include, or an old vendor domain that no longer publishes SPF, eats one of those, and the record also fails with a permerror on its own, because an include must point at a real SPF record.&lt;/p&gt;

&lt;p&gt;Receivers usually evaluate the record from left to right and stop at the first match, so in theory a sender listed early never hits the limit. Don't plan around that. Mail from a server that isn't in your record, including every forged message, walks the whole thing, and you want those to get a clean fail rather than a permerror.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count it by hand once
&lt;/h2&gt;

&lt;p&gt;It's worth doing this once with &lt;code&gt;dig&lt;/code&gt; so the number makes sense. Start with your own record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT example.com | &lt;span class="nb"&gt;grep &lt;/span&gt;spf1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then follow each include. This is real output for one popular sending service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dig +short TXT sendgrid.net
"v=spf1 ip4:167.89.0.0/17 ip4:208.117.48.0/20 ip4:50.31.32.0/19 ip4:198.37.144.0/20 ip4:198.21.0.0/21 ip4:192.254.112.0/20 ip4:168.245.0.0/17 ip4:149.72.0.0/16 ip4:159.183.0.0/16 ip4:134.128.64.0/18 ip4:159.26.176.0/20 include:ab.sendgrid.net ~all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven ranges are free, but the &lt;code&gt;include:ab.sendgrid.net&lt;/code&gt; at the end isn't. So &lt;code&gt;include:sendgrid.net&lt;/code&gt; in your record costs two lookups, not one. Repeat that for every include and add it up. If dig's output format is unfamiliar, our walkthrough on &lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;reading dig output&lt;/a&gt; explains each part.&lt;/p&gt;

&lt;p&gt;One detail that trips people up: a long TXT record comes back as several quoted strings, like &lt;code&gt;"...ip4:62.253.2" "27.114..."&lt;/code&gt;. Receivers glue those together with no space in between, so that's one address, &lt;code&gt;62.253.227.114&lt;/code&gt;, not two broken ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count it with one command
&lt;/h2&gt;

&lt;p&gt;Doing that by hand for eight vendors gets old fast, so we wrote a small read-only script that walks the whole tree the way a receiver does. It prints every lookup with a running total, the cost of each term in your record, and the void lookup count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./email/spf-lookup-count.sh example.com
./email/spf-lookup-count.sh &lt;span class="nt"&gt;--doh&lt;/span&gt; example.com          &lt;span class="c"&gt;# ask 1.1.1.1 over HTTPS&lt;/span&gt;
./email/spf-lookup-count.sh &lt;span class="nt"&gt;--record&lt;/span&gt; &lt;span class="s2"&gt;"v=spf1 ..."&lt;/span&gt; example.com   &lt;span class="c"&gt;# test a draft&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a real run against a draft record for a business that sends through a mailbox provider, a transactional mail service, a newsletter tool, a CRM, a help desk and its web host:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09324rxqjxgic2nudqv1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09324rxqjxgic2nudqv1.png" alt="Terminal screenshot of spf-lookup-count.sh checking a draft SPF record for example.com with a, mx and six includes: the tree shows a running total that reaches 12, with the eleventh and twelfth lookups inside the web host include, a cost table per term, and the result FAIL, 12 DNS lookups over the limit of 10, exit code 2" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Twelve lookups for eight terms. The web host's include alone costs three, because it includes two relays of its own. Nothing in that record is wrong on its face, and every term looks like one lookup when you read it. That's exactly why this error is so common.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting back under 10
&lt;/h2&gt;

&lt;p&gt;Work through these in order. The first ones are free and safe, the last one needs care.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe876wsz4oljxqs7g8e66.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe876wsz4oljxqs7g8e66.png" alt="Checklist of five ways to get an SPF record back under 10 lookups, in order: remove senders you no longer use, check the Return-Path before keeping an include, swap a and mx for ip4 addresses, move bulk mail to a subdomain with its own record, and flatten only with a tool that refreshes the ranges automatically" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remove senders you no longer use.&lt;/strong&gt; Old newsletter tools and trial CRMs stay in SPF for years. Ask around before deleting, but each dead include is pure cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check which domain SPF is checked against.&lt;/strong&gt; SPF looks at the envelope sender, the Return-Path, not the From address people see. Many bulk senders use their own bounce domain, or a subdomain of yours like &lt;code&gt;bounces.example.com&lt;/code&gt; with its own SPF record. If that's the case for a service, its include on your root record does nothing. Open a message it sent, look at Return-Path and the &lt;code&gt;spf=&lt;/code&gt; result, and only then remove it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swap &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;mx&lt;/code&gt; for addresses.&lt;/strong&gt; &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;mx&lt;/code&gt; each cost a lookup just to say "the web server" and "the mail server". If those servers have fixed IPs, write &lt;code&gt;ip4:192.0.2.10&lt;/code&gt; instead. And if your outgoing mail already goes through the mailbox provider's include, &lt;code&gt;mx&lt;/code&gt; is often redundant, because the MX hosts receive mail and don't send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move a heavy sender to a subdomain.&lt;/strong&gt; Marketing mail from &lt;code&gt;news.example.com&lt;/code&gt; gets its own SPF record with its own budget of 10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flatten as a last resort.&lt;/strong&gt; Flattening means replacing an include with the IP ranges it resolves to. It works today and breaks quietly the day the vendor adds a new range, so only do it with a tool that refreshes the list on a schedule, and never for a large provider that changes ranges often.&lt;/p&gt;

&lt;p&gt;In the draft above, replacing &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;mx&lt;/code&gt; with the server's IP and dropping the newsletter include (say it sends with its own Return-Path) brings the count to 9. That's legal, but close. One more vendor and it's back over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test before you publish
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;--record&lt;/code&gt; counts a draft without touching DNS, you can try a change first and publish only the version that passes. When you do publish, keep one SPF record per name. Two &lt;code&gt;v=spf1&lt;/code&gt; TXT records on the same name is its own permerror, and it's the most common mistake when a new vendor's setup guide says "add this record". Merge their include into the existing one instead. Our guide on &lt;a href="https://blog.progineous.com/how-to-keep-email-out-of-spam/" rel="noopener noreferrer"&gt;keeping email out of spam&lt;/a&gt; covers the rest of the record set.&lt;/p&gt;

&lt;p&gt;After the change, send a message to an outside mailbox and check that the header now says &lt;code&gt;spf=pass&lt;/code&gt;. Lookups are cached, so give it the record's TTL before you judge.&lt;/p&gt;

&lt;p&gt;The script also has a &lt;code&gt;-q&lt;/code&gt; flag that prints one line, so you can run it from cron and catch the day a vendor adds a nested include and pushes you over. It's free in our server toolkit on GitLab: &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/email/spf-lookup-count.sh" rel="noopener noreferrer"&gt;email/spf-lookup-count.sh&lt;/a&gt;. If you're setting up a new site and mailbox anyway, &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; has shared hosting at $29 for the first year with the code new-2026, valid until December 1, 2026.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/spf-too-many-dns-lookups/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>dns</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why Your Site Loads on www but Not on the Bare Domain (and How to Fix It)</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:07:40 +0000</pubDate>
      <link>https://dev.to/progineous/why-your-site-loads-on-www-but-not-on-the-bare-domain-and-how-to-fix-it-1mp</link>
      <guid>https://dev.to/progineous/why-your-site-loads-on-www-but-not-on-the-bare-domain-and-how-to-fix-it-1mp</guid>
      <description>&lt;p&gt;Someone types your domain without the www and gets an error page, a certificate warning or a browser that spins until it gives up. Type it with www and the site is fine. Or the exact reverse.&lt;/p&gt;

&lt;p&gt;The cause is almost always simple: &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;www.example.com&lt;/code&gt; are two separate names, and each one needs its own DNS record, its own spot on the certificate and its own line in the web server config. Miss any one of those for one name and you get a domain not working without www (or with it). Here's how to find which piece is missing, layer by layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four addresses, one site
&lt;/h2&gt;

&lt;p&gt;Add http and https to the two names and a visitor can reach your site four different ways. All four should end on the same URL, through a single 301 redirect at most.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flo27e0pt0vw91x23ndbw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flo27e0pt0vw91x23ndbw.png" alt="Diagram of the four ways to reach one site: http://example.com, http://www.example.com and https://www.example.com each send a 301 redirect to https://example.com/, the one canonical URL that answers 200, with a note that each name needs a DNS record, a place on the certificate and a server_name entry" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pick the one you want people to see and treat the other three as doors that redirect to it. The checks below follow the order a browser hits them: DNS, the server, the certificate, then the redirects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: does each name have a record?
&lt;/h2&gt;

&lt;p&gt;Ask for both names directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short example.com A
dig +short www.example.com A
dig +short example.com AAAA
dig +short www.example.com AAAA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty answer for one name is often the whole problem: the apex got an A record at launch and &lt;code&gt;www&lt;/code&gt; never did, or the reverse after a move. If you want the full picture of what dig is telling you, we have a separate guide on &lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;reading dig output&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A common healthy setup is an A record on the apex and a CNAME on www pointing at it. This is real output from a domain set up that way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dig www.github.com +noall +answer
www.github.com.     3210  IN  CNAME  github.com.
github.com.         20    IN  A      20.29.134.23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CNAME on www means it follows whatever address the apex has, so you only maintain one A record. Our walkthrough on &lt;a href="https://blog.progineous.com/how-to-add-a-cname-record/" rel="noopener noreferrer"&gt;how to add a CNAME record&lt;/a&gt; covers the panel side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the apex can't be a CNAME
&lt;/h3&gt;

&lt;p&gt;The reverse doesn't work. A CNAME can't share a name with any other record, and the apex always carries SOA and NS records, plus MX and TXT for most domains. So &lt;code&gt;example.com CNAME something.host.net&lt;/code&gt; is against the DNS rules. Most panels refuse it, and the ones that accept it can break mail and other records at the apex.&lt;/p&gt;

&lt;p&gt;If your host gives you a hostname instead of an IP, look for a record type called ALIAS or ANAME, or a feature called CNAME flattening. Those resolve the target on the DNS server and answer with plain A and AAAA records at the apex, so it works without breaking the rules. If your DNS host has none of those, ask the host for a fixed IP to use on the apex and keep the CNAME on www.&lt;/p&gt;

&lt;h3&gt;
  
  
  The AAAA record nobody remembers
&lt;/h3&gt;

&lt;p&gt;Sometimes the A records were updated during a move, but the old AAAA record still points at the previous server. Visitors on IPv4 get the new site. Visitors on IPv6, which today means a lot of mobile networks, may get the old server, an empty default page or a timeout. Browsers do fall back to IPv4 when IPv6 fails to connect, but if the old address still answers, there's nothing to fall back from.&lt;/p&gt;

&lt;p&gt;If one name has an AAAA and the other doesn't, or the AAAA doesn't belong to your current server, update it or delete it. Then test it on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-6&lt;/span&gt; &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer 2: is the server answering for that name?
&lt;/h2&gt;

&lt;p&gt;DNS can be perfect and the server can still serve the wrong thing. A web server picks the site by the hostname in the request, so if the config only lists &lt;code&gt;example.com&lt;/code&gt;, a request for &lt;code&gt;www.example.com&lt;/code&gt; lands on the default site. That's the classic "www shows the hosting company's welcome page" situation.&lt;/p&gt;

&lt;p&gt;Check the first response for all four:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://example.com/  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://www.example.com/  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want one 200 (on your canonical URL) and three 301s whose &lt;code&gt;location&lt;/code&gt; is that same URL. A 200 on two of them means two copies of the site. In Nginx, check that both names appear in a &lt;code&gt;server_name&lt;/code&gt; line somewhere; in Apache, look for &lt;code&gt;ServerAlias&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: does the certificate cover both names?
&lt;/h2&gt;

&lt;p&gt;This one is easy to miss, because DNS and the server are fine and the browser just shows a scary warning page. A certificate lists the names it's valid for, and a request for any other name gets a browser warning, even if the next step would have redirected away.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; example.com:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; www.example.com &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-ext&lt;/span&gt; subjectAltName
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once with each name after &lt;code&gt;-servername&lt;/code&gt;. Both names need to be in the list. Note that a wildcard like &lt;code&gt;*.example.com&lt;/code&gt; covers www but not the bare &lt;code&gt;example.com&lt;/code&gt;, so a wildcard certificate usually lists the apex as well. With Certbot, ask for both names in one certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; example.com &lt;span class="nt"&gt;-d&lt;/span&gt; www.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you just renewed and still see the old name list, the server may be holding the old file in memory. Our guide to &lt;a href="https://blog.progineous.com/check-ssl-certificate-expiry/" rel="noopener noreferrer"&gt;checking SSL certificate expiry&lt;/a&gt; shows how to spot a renewed certificate that never got loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: redirects that go in circles
&lt;/h2&gt;

&lt;p&gt;A redirect loop shows up as "too many redirects" in the browser. Follow the chain and the loop prints itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sIL&lt;/span&gt; &lt;span class="nt"&gt;--max-redirs&lt;/span&gt; 5 https://www.example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP|location)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server redirects www to the bare name while the app (in WordPress, the Site Address under Settings &amp;gt; General) redirects the bare name back to &lt;a href="http://www" rel="noopener noreferrer"&gt;www&lt;/a&gt;. Make both agree.&lt;/li&gt;
&lt;li&gt;A proxy or CDN in front talks to your server over plain HTTP, and your server redirects every HTTP request to HTTPS. The proxy asks again over HTTP, forever. Switch the proxy to full HTTPS to the origin, or redirect based on the forwarded protocol header.&lt;/li&gt;
&lt;li&gt;Two rules, one in the server config and one in &lt;code&gt;.htaccess&lt;/code&gt;, each "fixing" the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8bgvefq2l8pf8wvehtf7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8bgvefq2l8pf8wvehtf7.png" alt="Table matching what you see to where to look: no response or name not resolved on one name means a missing A or AAAA record, checked with dig; the host welcome page means the name is missing from server_name or ServerAlias, checked with curl -sI; a certificate warning on one name means the certificate does not list it, checked with openssl s_client; too many redirects means two rules, or a proxy talking plain HTTP to the server, checked with curl -sIL; and working on some networks only means a stale AAAA record, checked with curl -6" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick one name and 301 the rest
&lt;/h2&gt;

&lt;p&gt;Once DNS, certificate and server all cover both names, send everything to one place. For a bare canonical name in Nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="nv"&gt;$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="nv"&gt;$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real site then lives in a third &lt;code&gt;server&lt;/code&gt; block with &lt;code&gt;listen 443 ssl&lt;/code&gt; and &lt;code&gt;server_name example.com&lt;/code&gt;. On Apache or shared hosting, the same thing in &lt;code&gt;.htaccess&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTPS} &lt;span class="ss"&gt;off&lt;/span&gt; [OR]
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_HOST} ^www\. [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ https://example.com%{REQUEST_URI} [L,R=301]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two habits save a lot of pain. Test with a 302 first and switch to 301 once it works, because browsers cache a 301 and keep following a wrong one even after you fix the server. And keep &lt;code&gt;$request_uri&lt;/code&gt; (or &lt;code&gt;%{REQUEST_URI}&lt;/code&gt;) in the target so deep links land on the same page instead of the home page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check all four in one go
&lt;/h2&gt;

&lt;p&gt;Running those commands for every variant gets tedious, so we wrote a read-only script that does it. It looks up A, AAAA and CNAME for both names, requests all four variants pinned to the address DNS returned, follows the redirects, reads the certificate each name serves and tells you whether every variant ends on the same URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./dns/www-apex-check.sh example.com
./dns/www-apex-check.sh &lt;span class="nt"&gt;--doh&lt;/span&gt; example.com   &lt;span class="c"&gt;# ask 1.1.1.1 over HTTPS instead of the local resolver&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmcqge0bxwhuu9c3istb4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmcqge0bxwhuu9c3istb4.png" alt="Terminal screenshot of www-apex-check.sh run against progineous.com: both names have the same two A records and two AAAA records and no CNAME, http and https on www and the bare name all end on https://progineous.com/ with HTTP 200, the certificate on both names lists progineous.com and *.progineous.com and covers both, the IPv6 test is skipped because the test machine has no IPv6 route, and the result is OK with exit code 0" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That run is against our own domain. Every variant ends on &lt;code&gt;https://progineous.com/&lt;/code&gt; and the certificate covers both names, but &lt;code&gt;http://www&lt;/code&gt; takes two hops (to https www, then to the bare name). A single rule straight to the final URL would save a round trip. When something is wrong, the script ends with &lt;code&gt;FAIL&lt;/code&gt; or &lt;code&gt;WARN&lt;/code&gt; lines that name the problem, such as a CNAME at the apex, a certificate that only covers one name or a redirect loop, and exits with 1 or 2 so you can use it in monitoring.&lt;/p&gt;

&lt;p&gt;The script is free in our server toolkit on GitLab: &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/dns/www-apex-check.sh" rel="noopener noreferrer"&gt;dns/www-apex-check.sh&lt;/a&gt;. If you're setting up a new site anyway, &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; has shared hosting at $29 for the first year with the code new-2026 (valid until December 1, 2026) and VPS plans from $6.96/mo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should you use www or the bare domain?
&lt;/h3&gt;

&lt;p&gt;Either works. The bare name is shorter to type, while www makes CDNs and cookie scoping a little easier on big setups. What matters is picking one and redirecting the other three variants to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Domain not working without www, but DNS looks right?
&lt;/h3&gt;

&lt;p&gt;Then check the certificate and the server config next. Run the openssl command above with the bare name after &lt;code&gt;-servername&lt;/code&gt;, and make sure that name is in &lt;code&gt;server_name&lt;/code&gt; or &lt;code&gt;ServerAlias&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long until a fixed record works everywhere?
&lt;/h3&gt;

&lt;p&gt;As long as the old record's TTL, which is often an hour or less but can be a day. A missing record can also be cached as "doesn't exist" for a while, based on the zone's SOA record.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/domain-works-with-www-not-without/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dns</category>
      <category>webdev</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Is a Disk Dying in Your Dedicated Server? How to Check Disk Health on Linux</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:34:15 +0000</pubDate>
      <link>https://dev.to/progineous/is-a-disk-dying-in-your-dedicated-server-how-to-check-disk-health-on-linux-12cb</link>
      <guid>https://dev.to/progineous/is-a-disk-dying-in-your-dedicated-server-how-to-check-disk-health-on-linux-12cb</guid>
      <description>&lt;p&gt;A dedicated server with two disks in RAID 1 can lose one of them and keep running as if nothing happened. That's the point of RAID, and it's also the trap. The site stays up, nobody gets an alert, and months later the second disk starts throwing errors while the first one has been dead the whole time.&lt;/p&gt;

&lt;p&gt;Disks rarely die without warning. They remap bad sectors, fail self-tests and log errors for weeks before they give up, and Linux can read all of it. Here's how to check disk health on Linux with &lt;code&gt;smartctl&lt;/code&gt; and &lt;code&gt;mdadm&lt;/code&gt;, what the numbers mean, and what to do when one of them looks bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick check
&lt;/h2&gt;

&lt;p&gt;If you only have a minute, run these three as root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/mdstat
smartctl &lt;span class="nt"&gt;-H&lt;/span&gt; /dev/sda
smartctl &lt;span class="nt"&gt;-A&lt;/span&gt; /dev/sda | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'Realloc|Pending|Offline_Unc|CRC'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first shows whether every RAID array still has all its members, the second gives the drive's own verdict, and the third shows the four counters that matter most on a SATA drive. Repeat the last two for each disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the disks and install smartmontools
&lt;/h2&gt;

&lt;p&gt;SMART data comes from the drive's firmware, and &lt;code&gt;smartctl&lt;/code&gt; from the smartmontools package reads it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;smartmontools
lsblk &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; NAME,MODEL,SIZE,ROTA,TRAN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ROTA&lt;/code&gt; is 1 for spinning disks and 0 for SSDs, and &lt;code&gt;TRAN&lt;/code&gt; shows whether a disk is SATA, SAS or NVMe. If you see &lt;code&gt;vda&lt;/code&gt; or &lt;code&gt;xvda&lt;/code&gt; instead, you're on a virtual machine. Virtual disks have no SMART data, and the physical drives are the host's job. That's one of the real differences when you &lt;a href="https://blog.progineous.com/cloud-server-vs-dedicated/" rel="noopener noreferrer"&gt;compare a cloud server with a dedicated one&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One big disk on a server that should have several usually means a hardware RAID controller. &lt;code&gt;smartctl&lt;/code&gt; can still reach the drives behind most of them, for example &lt;code&gt;smartctl -a -d megaraid,0 /dev/sda&lt;/code&gt; for the first disk on a MegaRAID card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PASSED doesn't mean healthy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;smartctl -H&lt;/code&gt; prints &lt;code&gt;SMART overall-health self-assessment test result: PASSED&lt;/code&gt; on most drives, including plenty that are on their way out. The verdict only flips to FAILED when a normalized attribute value drops below the threshold the manufacturer set, and those thresholds are generous. Treat FAILED as an emergency and PASSED as "keep reading".&lt;/p&gt;

&lt;p&gt;What you want is the raw values in &lt;code&gt;smartctl -A&lt;/code&gt;, the last column of the attribute table. On a SATA drive, four of them do most of the work:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq5uvfkikj6qfmthgi75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq5uvfkikj6qfmthgi75.png" alt="Diagram of the SMART counters worth watching: on SATA drives attribute 5 Reallocated_Sector_Ct, 197 Current_Pending_Sector, 198 Offline_Uncorrectable and 199 UDMA_CRC_Error_Count with what each means and what to do, and on NVMe drives Critical Warning, Available Spare, Percentage Used and Media and Data Integrity Errors with when each is bad" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 Reallocated_Sector_Ct&lt;/strong&gt;: sectors the drive gave up on and replaced from its spare pool. A handful that never changes can live for years. A number that grows every week is a drive that's failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;197 Current_Pending_Sector&lt;/strong&gt;: sectors the drive couldn't read and hasn't remapped yet. These are reads that already failed. On a RAID member, mdadm usually rewrites them from the other disk, and they either get remapped or clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;198 Offline_Uncorrectable&lt;/strong&gt;: sectors that failed during the drive's own background scan. Read it together with 197.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;199 UDMA_CRC_Error_Count&lt;/strong&gt;: errors on the cable between disk and controller, not on the platters. If this climbs, reseat or replace the cable or backplane slot before you blame the drive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ideally all four are zero. Write down anything above zero with the date, so you can see next week whether it moved.&lt;/p&gt;

&lt;h3&gt;
  
  
  NVMe drives report different numbers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;smartctl -a /dev/nvme0n1&lt;/code&gt; shows a short health section instead of the attribute table. Watch &lt;code&gt;Critical Warning&lt;/code&gt; (anything other than &lt;code&gt;0x00&lt;/code&gt; is bad), &lt;code&gt;Available Spare&lt;/code&gt; against &lt;code&gt;Available Spare Threshold&lt;/code&gt;, &lt;code&gt;Percentage Used&lt;/code&gt; (the share of the rated write endurance already used, and it can go past 100), and &lt;code&gt;Media and Data Integrity Errors&lt;/code&gt;, which should stay at zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask the drive to test itself
&lt;/h2&gt;

&lt;p&gt;Counters only change when the drive touches a bad area. A self-test makes it look on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;smartctl &lt;span class="nt"&gt;-t&lt;/span&gt; short /dev/sda      &lt;span class="c"&gt;# about two minutes&lt;/span&gt;
smartctl &lt;span class="nt"&gt;-t&lt;/span&gt; long /dev/sda       &lt;span class="c"&gt;# hours on a large disk, runs in the background&lt;/span&gt;
smartctl &lt;span class="nt"&gt;-l&lt;/span&gt; selftest /dev/sda
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests run inside the drive and leave your data alone, though a long test adds read load. The newest result is &lt;code&gt;# 1&lt;/code&gt;. You want &lt;code&gt;Completed without error&lt;/code&gt;. A line like &lt;code&gt;Completed: read failure&lt;/code&gt; with an LBA next to it means the drive found a sector it can't read, and that disk should go on the replacement list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the RAID state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/proc/mdstat&lt;/code&gt; is the fastest view of software RAID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;md2 : active raid1 sdb3[1] sda3[0]
      3902418944 blocks super 1.2 [2/2] [UU]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[2/2] [UU]&lt;/code&gt; means two members expected, two working. A degraded array shows &lt;code&gt;[2/1] [U_]&lt;/code&gt;, and a member marked &lt;code&gt;(F)&lt;/code&gt; has been kicked out as failed. For the full picture, including which partition is missing, run &lt;code&gt;mdadm --detail /dev/md2&lt;/code&gt; and check the &lt;code&gt;State&lt;/code&gt; line. &lt;code&gt;clean&lt;/code&gt; is good, while &lt;code&gt;degraded&lt;/code&gt; or &lt;code&gt;recovering&lt;/code&gt; needs attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  One script for all of it
&lt;/h2&gt;

&lt;p&gt;Checking every disk and array by hand gets old fast, so we wrote a small read-only script for it. It runs &lt;code&gt;smartctl -a&lt;/code&gt; on each disk, pulls out the counters above, adds the last self-test and the RAID state from &lt;code&gt;/proc/mdstat&lt;/code&gt;, and ends with a verdict and an exit code you can use from cron.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; ./disk/disk-health-check.sh
&lt;span class="nb"&gt;sudo&lt;/span&gt; ./disk/disk-health-check.sh &lt;span class="nt"&gt;-q&lt;/span&gt;   &lt;span class="c"&gt;# one line, for cron&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjio7yrqn24a2mbx3hd2f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjio7yrqn24a2mbx3hd2f.png" alt="Terminal screenshot of disk-health-check.sh on a server with two SATA disks and one NVMe drive: sda and the NVMe drive are OK, sdb passes the overall SMART check but shows 8 reallocated, 16 pending and 16 offline uncorrectable sectors and a failed self-test, so it is marked WARN, while both RAID 1 arrays md1 and md2 still show UU (both members up), ending with Result WARN and exit code 1" width="799" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In that run both arrays are still &lt;code&gt;[UU]&lt;/code&gt;, so nothing looks wrong from the outside. But &lt;code&gt;sdb&lt;/code&gt; has 8 reallocated and 16 pending sectors and failed its last short test. That's the early warning RAID hides, and the moment to check your backups (here's how to &lt;a href="https://blog.progineous.com/how-to-back-up-a-vps/" rel="noopener noreferrer"&gt;back up a server and prove the restore works&lt;/a&gt;) and book a disk swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replacing a failing disk in mdadm RAID 1
&lt;/h2&gt;

&lt;p&gt;Before anything else, confirm the backup is recent. Then note the serial number of the bad disk with &lt;code&gt;smartctl -i /dev/sdb&lt;/code&gt;, because that's how the data center tech finds the right drive in the chassis. Pulling the healthy one by mistake takes the server down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwly6v1tkczdblaptqy5l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwly6v1tkczdblaptqy5l.png" alt="Six step diagram for swapping a failing disk in mdadm RAID 1: check the backup, note the serial with smartctl -i, fail and remove the old partitions with mdadm, swap the disk and confirm its name with lsblk, copy the partition layout with sgdisk from the healthy disk, then add the partitions back and watch the rebuild in /proc/mdstat" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mark the old disk's partitions as failed and remove them from each array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mdadm &lt;span class="nt"&gt;--manage&lt;/span&gt; /dev/md2 &lt;span class="nt"&gt;--fail&lt;/span&gt; /dev/sdb3 &lt;span class="nt"&gt;--remove&lt;/span&gt; /dev/sdb3
mdadm &lt;span class="nt"&gt;--manage&lt;/span&gt; /dev/md1 &lt;span class="nt"&gt;--fail&lt;/span&gt; /dev/sdb2 &lt;span class="nt"&gt;--remove&lt;/span&gt; /dev/sdb2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the new disk is in, check its name with &lt;code&gt;lsblk&lt;/code&gt;, since it won't always come back as &lt;code&gt;sdb&lt;/code&gt;. Copy the partition layout from the healthy disk to the new one. Get the direction right, because this overwrites the target's partition table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sgdisk /dev/sda &lt;span class="nt"&gt;-R&lt;/span&gt; /dev/sdb     &lt;span class="c"&gt;# copy sda's GPT layout onto sdb&lt;/span&gt;
sgdisk &lt;span class="nt"&gt;-G&lt;/span&gt; /dev/sdb              &lt;span class="c"&gt;# give the copy new unique GUIDs&lt;/span&gt;
mdadm &lt;span class="nt"&gt;--manage&lt;/span&gt; /dev/md1 &lt;span class="nt"&gt;--add&lt;/span&gt; /dev/sdb2
mdadm &lt;span class="nt"&gt;--manage&lt;/span&gt; /dev/md2 &lt;span class="nt"&gt;--add&lt;/span&gt; /dev/sdb3
watch &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/mdstat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On an MBR disk, use &lt;code&gt;sfdisk -d /dev/sda | sfdisk /dev/sdb&lt;/code&gt; instead of &lt;code&gt;sgdisk&lt;/code&gt;. Install the boot loader on the new disk too (&lt;code&gt;grub-install /dev/sdb&lt;/code&gt; on BIOS systems), or the server won't boot if the other disk dies next. On UEFI, copy the EFI partition over as well. The rebuild can take hours, and the array stays usable meanwhile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the server tell you
&lt;/h2&gt;

&lt;p&gt;Two daemons that ship with these packages can email you instead of waiting for you to look. In &lt;code&gt;/etc/smartd.conf&lt;/code&gt;, this line watches every disk and schedules a short test every night and a long one on Saturdays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DEVICESCAN -a -o on -S on -s (S/../.././02|L/../../6/03) -m root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For RAID, set &lt;code&gt;MAILADDR you@example.com&lt;/code&gt; in &lt;code&gt;/etc/mdadm/mdadm.conf&lt;/code&gt; and send a test alert with &lt;code&gt;mdadm --monitor --scan --test --oneshot&lt;/code&gt;. Both need working outgoing mail on the server. If your disks are filling up rather than wearing out, that's a different problem, and our guide on &lt;a href="https://blog.progineous.com/vps-disk-full/" rel="noopener noreferrer"&gt;what's using the space on a full disk&lt;/a&gt; covers it.&lt;/p&gt;

&lt;p&gt;The script is free on GitLab: &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/disk/disk-health-check.sh" rel="noopener noreferrer"&gt;disk-health-check.sh in our server toolkit&lt;/a&gt;, and a daily cron line for it is in its header. If you'd rather leave physical disks to someone else entirely, &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; has VPS plans from $6.96/mo.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How often should you check disk health on Linux servers?
&lt;/h3&gt;

&lt;p&gt;Let smartd and mdadm watch all the time, and look at the counters yourself about once a week. The trend matters most, so compare with the last reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a disk with a few reallocated sectors safe to keep?
&lt;/h3&gt;

&lt;p&gt;A small number that stays flat for months is common on older drives. If it rises between checks, or pending sectors show up at the same time, plan the replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can smartctl check disks on a VPS?
&lt;/h3&gt;

&lt;p&gt;No. A VPS sees virtual disks without SMART data, and the provider watches the physical drives.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/check-disk-health-dedicated-server/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Moving Hosts? Test Every Page on the New Server Before You Change DNS</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:03:02 +0000</pubDate>
      <link>https://dev.to/progineous/moving-hosts-test-every-page-on-the-new-server-before-you-change-dns-4of4</link>
      <guid>https://dev.to/progineous/moving-hosts-test-every-page-on-the-new-server-before-you-change-dns-4of4</guid>
      <description>&lt;p&gt;The scary part of moving a site to a new host isn't copying the files. It's the moment you change the DNS record and hope. If something on the new server is broken, visitors find it before you do, and rolling back takes as long as the TTL says it does.&lt;/p&gt;

&lt;p&gt;You don't have to guess. Your own computer can talk to the new server under the real domain name while everyone else still sees the old one, so you can check pages, certificate, forms and redirects first and switch only when both copies match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lower the TTL a day or two early
&lt;/h2&gt;

&lt;p&gt;The TTL on your A record decides how long resolvers keep the old answer after you change it. Check it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +noall +answer example.com A
dig +noall +answer example.com A @ns1.your-dns-host.com&lt;span class="sb"&gt;```&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;% endraw %&lt;span class="o"&gt;}&lt;/span&gt;
The first asks your normal resolver, where the TTL counts down as the cached copy ages. The second asks the authoritative server and shows the full value. If it says 86400 &lt;span class="o"&gt;(&lt;/span&gt;one day&lt;span class="o"&gt;)&lt;/span&gt; or 14400, lower it to 300 &lt;span class="k"&gt;in &lt;/span&gt;your DNS panel, &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;wait &lt;/span&gt;at least the old TTL before you plan the switch. Resolvers that cached the record yesterday will hold it &lt;span class="k"&gt;for &lt;/span&gt;the full old value, no matter what you &lt;span class="nb"&gt;set &lt;/span&gt;today. If you&lt;span class="s1"&gt;'re unsure which line of {% raw %}`dig` output is which, we wrote a guide on [how to read dig output](https://blog.progineous.com/read-dig-output-dns/), and the reasons changes sometimes seem stuck are in [how long DNS changes take](https://blog.progineous.com/how-long-dns-changes-take/).

![Diagram of a host move in four steps: lower the A record TTL to 300 a day or two before, test the new server with site-compare.sh while only you can see it, change the A record on switch day and retire the old server a few days later, with two bars showing when the old and the new server serve visitors](https://blog.progineous.com/wp-content/uploads/2026/10/test-website-before-changing-dns-timeline.png)

## Test website before changing DNS, page by page

With the files and database copied over (the full order of steps is in our [step by step migration guide](https://blog.progineous.com/how-to-migrate-a-website/)), point your own machine at the new server. There are two ways, and you'&lt;/span&gt;ll probably use both.

&lt;span class="c"&gt;### One request at a time with curl --resolve&lt;/span&gt;

&lt;span class="o"&gt;{&lt;/span&gt;% raw %&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;curl &lt;span class="nt"&gt;--resolve&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;% endraw %&lt;span class="o"&gt;}&lt;/span&gt; tells curl which IP to use &lt;span class="k"&gt;for &lt;/span&gt;a name, &lt;span class="k"&gt;for &lt;/span&gt;that one &lt;span class="nb"&gt;command &lt;/span&gt;only. The URL, the SNI name and the Host header all stay the same, so you&lt;span class="s1"&gt;'re testing the real virtual host:



```bash
curl -sI --resolve example.com:443:203.0.113.20 https://example.com/
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against the old IP and the new IP and compare the status line, the &lt;code&gt;location&lt;/code&gt; header on redirects, and headers your app sets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clicking around with the hosts file
&lt;/h3&gt;

&lt;p&gt;For a real browser session, add a line to your hosts file (&lt;code&gt;/etc/hosts&lt;/code&gt; on Linux and macOS, &lt;code&gt;C:\Windows\System32\drivers\etc\hosts&lt;/code&gt; on Windows):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;203.0.113.20  example.com www.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your browser loads the new server. Log in to the admin area (on WordPress that's &lt;code&gt;/wp-login.php&lt;/code&gt;), open a few posts, run a search, try the cart. Put a test HTML comment on the new copy so you always know which server you're looking at, and remove the hosts line when you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the certificate on the new IP
&lt;/h2&gt;

&lt;p&gt;A page can load fine over HTTP and still fail for visitors because the new server has no valid certificate for your name yet. Ask the new IP for its certificate with the right server name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; 203.0.113.20:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; example.com &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-subject&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt; &lt;span class="nt"&gt;-dates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for your domain in the subject and an expiry date well ahead. Without &lt;code&gt;-servername&lt;/code&gt; many servers return a default certificate, which tells you nothing. One catch with Let's Encrypt: the usual HTTP challenge can't pass on the new server while DNS still points at the old one. You can copy the existing certificate across, use a DNS challenge, or plan to issue it right after the switch. Our post on &lt;a href="https://blog.progineous.com/check-ssl-certificate-expiry/" rel="noopener noreferrer"&gt;checking certificate expiry from the command line&lt;/a&gt; covers reading that output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare every URL, not just the home page
&lt;/h2&gt;

&lt;p&gt;The home page is rarely what breaks. It's the old link that used to redirect, a contact page that throws an error, a category page that comes back half empty. To catch those, take every URL from the sitemap and request each one from both servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/sitemap.xml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;loc&amp;gt;[^&amp;lt;]*'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;loc&amp;gt;//'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; urls.txt
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; u&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;o&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.com:443:203.0.113.10 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.com:443:203.0.113.20 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; urls.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On WordPress the sitemap at &lt;code&gt;/sitemap.xml&lt;/code&gt; is often an index that lists smaller sitemaps, so you may need to repeat the first step for each of those. Status codes alone miss pages that return 200 with the wrong content, so compare the title and response size too.&lt;/p&gt;

&lt;p&gt;That's exactly what our site-compare script does. Give it the new IP, optionally the old one (otherwise it uses whatever public DNS returns), and a list of URLs as arguments, from a file with &lt;code&gt;-f&lt;/code&gt;, or from a sitemap with &lt;code&gt;--sitemap&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./site-compare.sh &lt;span class="nt"&gt;--old&lt;/span&gt; 203.0.113.10 &lt;span class="nt"&gt;--new&lt;/span&gt; 203.0.113.20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sitemap&lt;/span&gt; https://example.com/sitemap.xml https://example.com/wp-login.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the sitemap from the old side and follows a sitemap index one level down. For each URL it sends the same GET request to both servers with &lt;code&gt;curl --resolve&lt;/code&gt;, does not follow redirects, and lines up status code, redirect target, page title and response size. Size differences above 15 percent get flagged (change that with &lt;code&gt;-t&lt;/code&gt;). It checks the certificate once per host on each side first, and if one fails to verify it skips those pages unless you pass &lt;code&gt;-k&lt;/code&gt;. The exit code is 0 when everything matches, 1 when it finds differences and 2 on errors, so it fits in a script too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0f7izgki7qe568brqc2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0f7izgki7qe568brqc2.png" alt="Terminal screenshot of site-compare.sh comparing example.com on an old and a new server from its sitemap: the certificate is ok on both, five URLs are OK and three are DIFF (the pricing page is 43 percent smaller, the contact page returns 500 on the new server and the old-pricing redirect is missing there), ending with 3 of 8 URLs differ and exit code 1" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In that run the redirect from &lt;code&gt;/old-pricing/&lt;/code&gt; is gone on the new server, which usually means rewrite rules didn't come along (common when moving from Apache to nginx, which ignores &lt;code&gt;.htaccess&lt;/code&gt;). The contact page returns 500, and the pricing page is 43 percent smaller, which on a real site points to a missing plugin, PHP extension or include. Each of those would have reached visitors on switch day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts a page check won't see
&lt;/h2&gt;

&lt;p&gt;Some things only show up when you test them on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forms and outgoing email.&lt;/strong&gt; Submit every form on the new server and confirm the message arrives. If the site sends mail itself, the new IP may need to be in your SPF record, or the mail goes to spam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs.&lt;/strong&gt; Run &lt;code&gt;crontab -l&lt;/code&gt; on the old server and recreate the jobs. On WordPress, check that WP-Cron or the system cron replacing it is running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded addresses.&lt;/strong&gt; Search config files and the database for the old IP or paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other DNS records.&lt;/strong&gt; If you only change the A record, mail (MX) and other records stay put. If you move name servers too, recreate every record first and compare them with &lt;code&gt;dig&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress specifics.&lt;/strong&gt; PHP version, file permissions on uploads, and caching plugins that stored the old server path are the usual suspects. Our guide on &lt;a href="https://blog.progineous.com/how-to-move-a-wordpress-site/" rel="noopener noreferrer"&gt;moving a WordPress site safely&lt;/a&gt; goes through them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmz0f4pp20d1a83sdp5z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmz0f4pp20d1a83sdp5z.png" alt="Checklist titled Before you change DNS with ten ticked items: TTL lowered to 300, every sitemap URL matches, certificate valid on the new IP, old redirects still work, forms send and email arrives, cron jobs recreated, admin login works, other DNS records copied, hosts file line removed and old server stays on" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Switch, then keep the old server running
&lt;/h2&gt;

&lt;p&gt;When the comparison comes back clean, change the A record (and &lt;code&gt;www&lt;/code&gt; if it's a separate record). With a 300 second TTL most visitors move within minutes. Check from the outside with &lt;code&gt;dig +short example.com&lt;/code&gt; against a couple of public resolvers, watch the access log on the new server fill up, and load the site with a plain &lt;code&gt;curl -s https://example.com/ | grep&lt;/code&gt; for the test comment you left, so you know public DNS now lands on the new copy.&lt;/p&gt;

&lt;p&gt;Keep the old server online for a few days. Some resolvers hold records longer than they should, and if something goes wrong, pointing the record back is a one line change. Once traffic on the old server drops to nothing, put the TTL back to a normal value like 3600 and shut the old server down.&lt;/p&gt;

&lt;p&gt;The script is free and read only, it sends GET requests and changes nothing. You can grab &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/migration/site-compare.sh" rel="noopener noreferrer"&gt;site-compare.sh from our server toolkit on GitLab&lt;/a&gt;. And if you're still deciding where the site should live next, &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; has VPS plans from $6.96/mo.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can you test a site on a new server without editing the hosts file?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;curl --resolve example.com:443:NEW_IP https://example.com/&lt;/code&gt; sends one request to the new server under the real name. You only need the hosts file to click through the site in a browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  How far ahead should you lower the TTL?
&lt;/h3&gt;

&lt;p&gt;At least as long as the current TTL. If the record has a TTL of 86400, lower it to 300 a full day before the switch, so every cached copy has expired by the time you change the IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the new server show the wrong certificate?
&lt;/h3&gt;

&lt;p&gt;Usually it has no certificate for your name yet, or you connected without the server name. Test with &lt;code&gt;openssl s_client -servername example.com&lt;/code&gt;. With Let's Encrypt, copy the current certificate or use a DNS challenge, since the HTTP challenge can't pass before the switch.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/test-website-before-changing-dns/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Is Your Cloud Server Slow Because of CPU Steal? How to Check</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:04:26 +0000</pubDate>
      <link>https://dev.to/progineous/is-your-cloud-server-slow-because-of-cpu-steal-how-to-check-16o4</link>
      <guid>https://dev.to/progineous/is-your-cloud-server-slow-because-of-cpu-steal-how-to-check-16o4</guid>
      <description>&lt;p&gt;Your cloud server feels slower than it did last month. Pages take longer, a cron job that used to finish in two minutes now needs five, and yet nothing changed in your code. Before you start tuning PHP or buying a bigger plan, check one number that a lot of people never look at: CPU steal time.&lt;/p&gt;

&lt;p&gt;On a cloud server or VPS, your vCPUs are threads on a physical host that you share with other machines. Steal is the time your vCPU was ready to run but had to wait because the hypervisor gave the physical core to someone else. Your server can't use that time, and nothing inside it can get it back. The good news is that Linux counts it, so you can see it with tools that are already installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to see CPU steal time
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;top&lt;/code&gt; and look at the &lt;code&gt;%Cpu(s)&lt;/code&gt; line. The last field, &lt;code&gt;st&lt;/code&gt;, is steal. &lt;code&gt;vmstat&lt;/code&gt; shows the same thing in its &lt;code&gt;st&lt;/code&gt; column, and it's easier to watch over a few seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;top &lt;span class="nt"&gt;-bn1&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 5
vmstat 2 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg96nnpt3hlhf5z2hj1oh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg96nnpt3hlhf5z2hj1oh.png" alt="Terminal screenshot of top and vmstat on a busy 2 vCPU VPS: the top CPU line shows 61.4 us, 19.6 id and 7.3 st, and vmstat 2 5 shows the st column between 7 and 11 percent while us stays around 60" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ignore the first &lt;code&gt;vmstat&lt;/code&gt; line. It's an average since boot, not a current reading. The lines after it are 2-second samples, and in that output steal sits between 7 and 11 percent while the server is busy. Put differently, for roughly every ten seconds of CPU the server wanted, close to one went to another tenant.&lt;/p&gt;

&lt;p&gt;If you want it per vCPU, &lt;code&gt;mpstat&lt;/code&gt; from the &lt;code&gt;sysstat&lt;/code&gt; package splits it out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;sysstat
mpstat &lt;span class="nt"&gt;-P&lt;/span&gt; ALL 2 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;%steal&lt;/code&gt; column there is the same counter. All of these tools read it from the eighth number on the &lt;code&gt;cpu&lt;/code&gt; line of &lt;code&gt;/proc/stat&lt;/code&gt;, so they agree with each other, give or take when each one takes its sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the number means something
&lt;/h2&gt;

&lt;p&gt;CPU steal time only appears when your server asks for CPU. A machine that sits at 95% idle can show zero steal on a host that is completely overloaded, simply because it never asks for more than a sliver. So a quick look at 3 a.m. tells you very little. Measure while the site is busy, during a deploy, or while a heavy job runs.&lt;/p&gt;

&lt;p&gt;As a rough guide, this is how we read it during busy periods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under 5%&lt;/strong&gt;: normal on shared virtual hardware. Leave it alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 to 15%&lt;/strong&gt;: worth watching. Collect data at different hours before drawing conclusions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Above 15% for long stretches&lt;/strong&gt;: you'll feel it in response times, and it's worth raising with your provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail that confuses people: high steal also pushes up the load average. Your processes are still runnable while they wait for the host, so they count toward load even though the server isn't doing more work. A load of 3 on a 2 vCPU server with 12% steal is a different problem from a load of 3 with zero steal.&lt;/p&gt;

&lt;p&gt;Also check what kind of virtualization you're on with &lt;code&gt;systemd-detect-virt&lt;/code&gt;. On KVM and Xen the steal counter is filled in. Inside an LXC or OpenVZ container it often stays at zero no matter what the host is doing, so a zero there doesn't prove much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the pattern
&lt;/h2&gt;

&lt;p&gt;A single reading tells you steal exists. The shape over a day tells you why.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdre1usshtf5vyv6derfi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdre1usshtf5vyv6derfi.png" alt="Three small charts of CPU steal and your own CPU use over a day, with what each pattern usually means: steal that follows your load points to a CPU limit or used-up CPU credits; steal that jumps at the same hours while your load stays flat points to a busy host; steal near zero on a slow server points back to disk, memory or the app" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steal follows your own load.&lt;/strong&gt; It climbs when your app gets busy and stays high after a long burst. That usually points to a CPU limit on the plan, or to a burstable plan that runs on CPU credits. When the credits run out, the throttling often shows up as steal. Check your plan's CPU terms and the credit balance in the provider panel, if there is one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steal rises at the same hours whatever you do.&lt;/strong&gt; Your traffic is flat, but steal jumps every evening or every night at 2 a.m. That's the physical host being busy with other tenants. It's not something you can fix from inside the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steal stays near zero, but the server is slow.&lt;/strong&gt; Then the slowness is yours. Look at &lt;code&gt;wa&lt;/code&gt; (waiting on disk), memory and swap, or the app itself. The &lt;a href="https://blog.progineous.com/vps-disk-full/" rel="noopener noreferrer"&gt;disk full guide&lt;/a&gt; and the &lt;a href="https://blog.progineous.com/how-much-ram-a-vps-needs/" rel="noopener noreferrer"&gt;RAM sizing guide&lt;/a&gt; are good places to continue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep a record with sar
&lt;/h2&gt;

&lt;p&gt;The second pattern is hard to prove with one screenshot. &lt;code&gt;sar&lt;/code&gt;, also from &lt;code&gt;sysstat&lt;/code&gt;, saves CPU stats every ten minutes so you can look back. On Debian and Ubuntu the collection is off by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/^ENABLED="false"/ENABLED="true"/'&lt;/span&gt; /etc/default/sysstat
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; sysstat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a day or two you can pull the history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sar &lt;span class="nt"&gt;-u&lt;/span&gt;                              &lt;span class="c"&gt;# today, one line per 10 minutes&lt;/span&gt;
sar &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/sysstat/sa24     &lt;span class="c"&gt;# the 24th of this month&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;%steal&lt;/code&gt; column, lined up against &lt;code&gt;%user&lt;/code&gt;, shows which of the patterns you're dealing with. On RHEL-based systems the files live in &lt;code&gt;/var/log/sa/&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;It also helps to have a simple benchmark you can repeat. Run the same fixed workload at a quiet hour and at a busy one and compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl speed &lt;span class="nt"&gt;-seconds&lt;/span&gt; 10 sha256     &lt;span class="c"&gt;# about a minute, six block sizes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers depend on the CPU model, so don't compare them across providers. Compare them on the same server over time. If the throughput drops at the same hours that &lt;code&gt;sar&lt;/code&gt; shows steal going up, you have your answer in two places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it in one command
&lt;/h2&gt;

&lt;p&gt;We put the sampling into a small script. It reads &lt;code&gt;/proc/stat&lt;/code&gt; at a fixed interval, prints user, system, iowait, steal and idle for each sample, and ends with the average and peak steal plus the share since boot. Because steal only shows under load, &lt;code&gt;--busy&lt;/code&gt; keeps every vCPU at 100% for the length of the sample. It asks before it starts and stops the load when it's done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./performance/cpu-steal-check.sh &lt;span class="nt"&gt;--busy&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfrpg56x0310qdzjc7ll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfrpg56x0310qdzjc7ll.png" alt="Terminal screenshot of cpu-steal-check.sh with --busy on a 2 vCPU KVM VPS: ten 2-second samples with steal between 6.0 and 12.0 percent, an average of 8.9 percent and a peak of 12.0, 2.1 percent since boot and a WARN result" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;-q&lt;/code&gt; it prints a single line and returns a non-zero exit code on a warning, so it fits in cron and builds up a log you can send to support. You can get &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/performance/cpu-steal-check.sh" rel="noopener noreferrer"&gt;cpu-steal-check.sh from our server toolkit on GitLab&lt;/a&gt;; it's read-only apart from that optional load.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;Once you have the data, the options are fairly short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send it to your provider.&lt;/strong&gt; Times, &lt;code&gt;sar&lt;/code&gt; output and the script log are far more useful than "the server feels slow". They can often move the VM to a quieter host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the plan type.&lt;/strong&gt; If steal follows your own load, you may be on a shared or burstable CPU plan that is doing exactly what it was sold to do. A plan with dedicated vCPUs, or a step up in size, fixes that. The &lt;a href="https://blog.progineous.com/how-to-scale-a-cloud-server/" rel="noopener noreferrer"&gt;guide to scaling a cloud server&lt;/a&gt; covers how to do it one step at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smooth out your own peaks.&lt;/strong&gt; Move heavy cron jobs, backups and image processing away from your busiest hours, so you ask for less CPU at the moments it's hardest to get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider dedicated hardware.&lt;/strong&gt; If steal keeps coming back after a move and the workload is steady and heavy, a dedicated server takes the neighbors out of the picture entirely. The &lt;a href="https://blog.progineous.com/cloud-server-vs-dedicated/" rel="noopener noreferrer"&gt;cloud vs dedicated comparison&lt;/a&gt; goes through the trade-offs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're testing a new home for a workload anyway, the same checks work on a fresh &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; VPS: run the script under load at a few different hours before you move anything.&lt;/p&gt;

&lt;p&gt;CPU steal time is one of the few performance problems you can't tune away from inside the server. What you can do is measure it properly, keep the numbers, and make the decision with data instead of a hunch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/vps-disk-full/" rel="noopener noreferrer"&gt;Disk Full on a Linux VPS? How to Find What Is Using the Space&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-scale-a-cloud-server/" rel="noopener noreferrer"&gt;How to Scale a Cloud Server One Step at a Time&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/cloud-server-vs-dedicated/" rel="noopener noreferrer"&gt;Cloud Server vs Dedicated Server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/cpu-steal-time/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>cloud</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Prepare a Domain Transfer So DNS and Email Keep Working</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:04:02 +0000</pubDate>
      <link>https://dev.to/progineous/how-to-prepare-a-domain-transfer-so-dns-and-email-keep-working-j3p</link>
      <guid>https://dev.to/progineous/how-to-prepare-a-domain-transfer-so-dns-and-email-keep-working-j3p</guid>
      <description>&lt;p&gt;Moving a domain to a new registrar should be one of the dullest jobs in IT. You switch off the lock, copy a code, pay, and a few days later the renewal invoice comes from somewhere else. The website, the mailboxes and the DNS records don't have to notice anything.&lt;/p&gt;

&lt;p&gt;When a transfer does go wrong, it is rarely the transfer itself. It's a lock nobody knew about, an approval email sent to an address that died years ago, or a nameserver change made in the same afternoon on a domain with DNSSEC. Those are all things you can see from a terminal before you start. So this is how we prepare a domain transfer, in the order it happens: two weeks before, the week before, on the day, during the wait, and after the name lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moves
&lt;/h2&gt;

&lt;p&gt;A registrar transfer changes one field at the registry: which registrar manages the name. Your nameservers, and therefore every DNS record, stay exactly as they are unless someone changes them. That's the whole reason a careful transfer causes no downtime.&lt;/p&gt;

&lt;p&gt;So the plan is simple. Move the registration first. Leave DNS where it is. If you also want to move DNS hosting, do it as a separate change after the transfer is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two weeks before: read the registry record
&lt;/h2&gt;

&lt;p&gt;Start with what the registry says about the name. RDAP gives you that as JSON, no account needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://rdap.verisign.com/com/v1/domain/example.com &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'{status, events: ([.events[] | {(.eventAction): .eventDate}] | add),
         dnssec: .secureDNS.delegationSigned}'&lt;/span&gt;
dig +short NS example.com
dig +noall +answer DS example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That RDAP server is the one for .com and .net. For other endings, the &lt;a href="https://blog.progineous.com/rdap-domain-lookup/" rel="noopener noreferrer"&gt;RDAP lookup guide&lt;/a&gt; shows how to find the right server from the IANA bootstrap file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp331fhz7veh3qsmsb95v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp331fhz7veh3qsmsb95v.png" alt="Terminal screenshot of an RDAP lookup for example.com with curl and jq showing the client transfer prohibited status, registration on 1995-08-14, expiration on 2027-08-13 and dnssec true, then dig showing two Cloudflare nameservers and a DS record with key tag 2371 and a TTL of 86400" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things to read in that output:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status.&lt;/strong&gt; &lt;code&gt;client transfer prohibited&lt;/code&gt; is the normal registrar lock. You turn it off yourself in the registrar panel, and under ICANN's Transfer Policy the registrar has to remove it within five calendar days if it doesn't offer a switch. &lt;code&gt;server transfer prohibited&lt;/code&gt;, any kind of &lt;code&gt;hold&lt;/code&gt;, &lt;code&gt;redemption period&lt;/code&gt; or &lt;code&gt;pending delete&lt;/code&gt; are different. Those need a conversation with the current registrar before anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates.&lt;/strong&gt; If the name was registered or transferred in the last 60 days, the current registrar is allowed to refuse the transfer. Some registries also publish a &lt;code&gt;transfer&lt;/code&gt; event, which tells you when the last move happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry.&lt;/strong&gt; Leave yourself a margin. If the name expires before the transfer goes through, the request can fail and you end up renewing under pressure anyway. When expiry is close, renew at the current registrar first. For gTLDs a completed transfer still adds a year on top, within the 10-year cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-day rules, and what ICANN has changed
&lt;/h2&gt;

&lt;p&gt;Today's rules come from the Transfer Policy updated in February 2024, which registrars had to apply by August 21, 2025. Two 60-day windows matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After registration or after a registrar transfer, the registrar may deny a new transfer for 60 days.&lt;/li&gt;
&lt;li&gt;After a change of registrant, the registrar must apply a 60-day transfer lock, unless you opted out before the change. A change of registrant means a real change to the registrant name, organization or email address, not a typo fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second one catches people. Fixing an old registrant email the week before a transfer can count as a change of registrant and lock the name for two months. If the email needs fixing, look for the opt-out first.&lt;/p&gt;

&lt;p&gt;On June 7, 2026 the ICANN Board adopted the Transfer Policy Review recommendations. When they take effect, the lock after registration and after a transfer becomes a fixed 30 days, the auth code becomes a one-time Transfer Authorization Code (TAC) valid for 14 days, and the 60-day change-of-registrant lock goes away. As of today, ICANN has not published an effective date, so the current 60-day rules still apply. Country-code endings like .uk or .de follow their own registry's rules either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  One week before: snapshot DNS and look at DNSSEC
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Take a snapshot you can rebuild from
&lt;/h3&gt;

&lt;p&gt;Even if you never plan to touch DNS, save what's published now. If a checkout page "helpfully" resets the nameservers, or you later move DNS hosting, this file is how you put everything back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;example.com
&lt;span class="k"&gt;for &lt;/span&gt;t &lt;span class="k"&gt;in &lt;/span&gt;SOA NS A AAAA MX TXT CAA&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;dig +noall +answer &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;-dns.txt"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;n &lt;span class="k"&gt;in &lt;/span&gt;www mail autodiscover _dmarc&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  for &lt;/span&gt;t &lt;span class="k"&gt;in &lt;/span&gt;A AAAA CNAME MX TXT&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;dig +noall +answer &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
done&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;-dns.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks for each type separately because many DNS providers answer &lt;code&gt;ANY&lt;/code&gt; with almost nothing. DKIM keys sit under selector names you have to know, such as &lt;code&gt;google._domainkey&lt;/code&gt; or &lt;code&gt;selector1._domainkey&lt;/code&gt;, and they're easy to forget. If any of the output looks odd, the &lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;dig output guide&lt;/a&gt; walks through each section.&lt;/p&gt;

&lt;h3&gt;
  
  
  DNSSEC is where the outages come from
&lt;/h3&gt;

&lt;p&gt;If the DS query returned a record, the domain is signed. The DS record at the registry is a fingerprint of the key your current DNS provider signs with. Validating resolvers check every answer against it.&lt;/p&gt;

&lt;p&gt;As long as the nameservers stay the same, the transfer doesn't affect this at all. The trouble starts when the nameservers move to a provider that signs with different keys, or doesn't sign. The old DS no longer matches, and resolvers that validate DNSSEC return SERVFAIL. For those users the website and email simply disappear.&lt;/p&gt;

&lt;p&gt;So the rule is: keep the nameservers through the transfer. If you're also moving DNS, do it afterwards in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove the DS record at the registrar.&lt;/li&gt;
&lt;li&gt;Wait at least the DS TTL (86400 seconds, one day, in the output above) so caches forget it.&lt;/li&gt;
&lt;li&gt;Switch the nameservers.&lt;/li&gt;
&lt;li&gt;Turn on DNSSEC at the new provider and add its new DS record at the registrar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are key rollover methods that keep the zone signed the whole time, but they need both DNS providers to cooperate. For most sites, a day or two unsigned is the simpler and safer trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transfer day: lock off, code, approval email
&lt;/h2&gt;

&lt;p&gt;Turn off the registrar lock, then request the auth code (some registrars call it the EPP code). Most registrars show it straight away, and one that has no self-service option has to send it within five calendar days. Treat it like a password: paste it into the new registrar's order and nowhere else.&lt;/p&gt;

&lt;p&gt;Before you submit, check which email address the approval message will go to. Public RDAP usually redacts the registrant contact, so don't rely on it here. Look in the current registrar's account instead. If that inbox is on the same domain, make sure the mail is still working there.&lt;/p&gt;

&lt;p&gt;At checkout, read the nameserver options. Some registrars offer to switch the name to their own nameservers. Say no for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five days in between
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbtg4y0midljcclf92vao.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbtg4y0midljcclf92vao.png" alt="Timeline of a domain transfer in five stages: two weeks before, read the registry record; one week before, save DNS and check DNSSEC; transfer day, remove the lock and submit; up to five days of pending transfer; after it lands, lock the name and check that NS and DS match the snapshot" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the new registrar sends the request, the registry marks the name &lt;code&gt;pending transfer&lt;/code&gt;. The current registrar sends a confirmation message within 24 hours. If it doesn't reject the request within five calendar days, the registry completes the transfer. Many registrars let you approve that message to finish early.&lt;/p&gt;

&lt;p&gt;During this window, DNS keeps resolving as normal. Don't edit records, contacts or nameservers while the transfer is pending.&lt;/p&gt;

&lt;h2&gt;
  
  
  After it lands
&lt;/h2&gt;

&lt;p&gt;Run the same RDAP and dig commands again and compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The registrar field shows the new registrar, and the new registrar's lock is on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NS&lt;/code&gt; and &lt;code&gt;DS&lt;/code&gt; match your snapshot exactly.&lt;/li&gt;
&lt;li&gt;The expiry date moved forward a year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then send a test email to and from the domain. If you publish DMARC, the next few &lt;a href="https://blog.progineous.com/read-dmarc-reports/" rel="noopener noreferrer"&gt;aggregate reports&lt;/a&gt; will confirm that SPF and DKIM still pass. Only after all of that, if moving DNS is part of the plan, start the DNSSEC steps above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it in one command
&lt;/h2&gt;

&lt;p&gt;We wrapped all of these checks into a read-only script. It reads the RDAP status and dates, flags locks and holds, reports DS records, and writes a DNS snapshot including the DKIM selectors you pass in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./domains/transfer-readiness.sh example.com &lt;span class="nt"&gt;--dkim&lt;/span&gt; google,selector1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisr6oignfx0h52ffnst2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisr6oignfx0h52ffnst2.png" alt="Terminal screenshot of transfer-readiness.sh checking example.com: WAIT on client transfer prohibited, registered 11394 days ago, 292 days left to expiry, a DS record published, two nameservers, a 16-record DNS snapshot saved, and the first lines of the snapshot file with SOA, NS, A, AAAA, MX and TXT records" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It changes nothing at the registrar or in DNS, so it's safe to run before and after the move. You can grab &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/domains/transfer-readiness.sh" rel="noopener noreferrer"&gt;transfer-readiness.sh from our server toolkit on GitLab&lt;/a&gt; along with the other domain scripts.&lt;/p&gt;

&lt;p&gt;The same checks apply whichever registrar the name is heading to, &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; included. Ten minutes with them up front is a lot cheaper than a week of untangling afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/rdap-domain-lookup/" rel="noopener noreferrer"&gt;Using RDAP to Check a Domain's Expiry Date and Lock Status&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;How to Read dig Output When DNS Looks Wrong&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/transfer-a-domain-safely/" rel="noopener noreferrer"&gt;How to Transfer a Domain Without Downtime&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/prepare-domain-transfer/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dns</category>
      <category>devops</category>
      <category>linux</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Check SSL Certificate Expiry Before Browsers Start Warning</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:09:06 +0000</pubDate>
      <link>https://dev.to/progineous/how-to-check-ssl-certificate-expiry-before-browsers-start-warning-33g5</link>
      <guid>https://dev.to/progineous/how-to-check-ssl-certificate-expiry-before-browsers-start-warning-33g5</guid>
      <description>&lt;p&gt;Most sites have had auto-renewing certificates for years, so an expired one feels like a thing of the past. Then a renewal fails quietly in August, nobody looks, and in October visitors get a full-page "Your connection is not private" warning while the checkout page loses every sale.&lt;/p&gt;

&lt;p&gt;The renewal tool usually isn't the whole story. The certificate on disk can be fresh while the web server is still handing out the old one from memory. A firewall change can block the challenge. A new AAAA record can send the certificate authority to a server that no longer exists. None of that shows up until a browser complains.&lt;/p&gt;

&lt;p&gt;So the habit worth building is simple: check SSL certificate expiry on what the server actually sends, not on what you think it has. Here is how we do it from a terminal, what the output means, and how to tell which part of the renewal broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "auto-renew is on" isn't enough anymore
&lt;/h2&gt;

&lt;p&gt;Two changes make this more pressing than it used to be.&lt;/p&gt;

&lt;p&gt;Certificates are getting shorter. Under the CA/Browser Forum rules, a public TLS certificate issued since March 15, 2026 can be valid for at most 200 days. That drops to 100 days in March 2027 and 47 days in March 2029. Let's Encrypt still issues 90-day certificates by default, moving to 64 days in February 2027 and 45 days in February 2028. Shorter lifetimes mean more renewals, and more chances for one to fail.&lt;/p&gt;

&lt;p&gt;The reminder emails are gone too. Let's Encrypt stopped sending expiry notification emails on June 4, 2025. If you were relying on that "your certificate expires in 20 days" message as a safety net, it no longer exists. You need your own check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one command worth memorizing
&lt;/h2&gt;

&lt;p&gt;Everything below builds on one small shell function. Put it in your &lt;code&gt;~/.bashrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cert&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:443"&lt;/span&gt; &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cert example.com&lt;/code&gt; opens a TLS connection and prints the certificate the server presents. The optional second argument lets you aim at a specific IP while still asking for the right name, which is handy before a migration or when you want to skip a CDN: &lt;code&gt;cert example.com 203.0.113.10&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-servername&lt;/code&gt; part matters. One IP often hosts many sites, and the server picks the certificate by the name the client sends (SNI). Recent OpenSSL sends the name by default when you connect to a hostname, but not when you connect to an IP, and older builds don't send it at all. Without it you may be looking at the server's default certificate and wondering why the dates are wrong.&lt;/p&gt;

&lt;p&gt;Pipe the result into &lt;code&gt;openssl x509&lt;/code&gt; to read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cert example.com | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-subject&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt; &lt;span class="nt"&gt;-dates&lt;/span&gt;
cert example.com | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-ext&lt;/span&gt; subjectAltName
cert example.com | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-checkend&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;21&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;86400&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsk9fxg7i0umqik3jxq14.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsk9fxg7i0umqik3jxq14.png" alt="Terminal screenshot of a small cert shell function wrapping openssl s_client, then openssl x509 showing the example.com certificate subject, issuer and dates with notAfter Dec 25 2026, its subject alternative names, checkend reporting Certificate will not expire, and Verify return code 0 (ok)" width="800" height="308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;notAfter&lt;/code&gt; is the line you care about, and it is always in GMT. &lt;code&gt;-checkend&lt;/code&gt; takes seconds and answers one question: will this certificate still be valid that far from now? It prints "Certificate will not expire" and exits 0, or "Certificate will expire" and exits 1, which makes it easy to use in a script. If you prefer a number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;cert example.com | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt; days left
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading the rest of the certificate
&lt;/h2&gt;

&lt;p&gt;Dates are only one way a certificate can fail. Two more lines in that output are worth a glance every time.&lt;/p&gt;

&lt;p&gt;The Subject Alternative Name list is what browsers match against. The old CN field is ignored for that. If &lt;code&gt;www.example.com&lt;/code&gt; isn't in the list, anyone who types the www version gets a name error even though the certificate is valid.&lt;/p&gt;

&lt;p&gt;The verify result tells you whether the chain is complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cert example.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Verify return code'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;0 (ok)&lt;/code&gt;. A result like &lt;code&gt;20 (unable to get local issuer certificate)&lt;/code&gt; usually means the server sends only the site certificate without the intermediate. Desktop browsers often paper over that, but curl, payment webhooks and older phones won't. With Let's Encrypt the fix is pointing the server at &lt;code&gt;fullchain.pem&lt;/code&gt; instead of &lt;code&gt;cert.pem&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renewed, but the old one is still being served
&lt;/h2&gt;

&lt;p&gt;This is the failure we see most on self-managed servers. Certbot renews on schedule and writes new files under &lt;code&gt;/etc/letsencrypt/live/&lt;/code&gt;, but nginx, Apache, Postfix or a Node app read the certificate once at startup and keep it in memory. Until something reloads them, visitors get the old one.&lt;/p&gt;

&lt;p&gt;The certbot nginx and Apache plugins reload the server for you. Certificates issued with &lt;code&gt;certonly&lt;/code&gt;, &lt;code&gt;--webroot&lt;/code&gt; or &lt;code&gt;--standalone&lt;/code&gt; don't, and neither does anything outside the web server, like a mail server using the same certificate.&lt;/p&gt;

&lt;p&gt;Comparing the file with the live connection makes it obvious:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbm985h5rp2qudnpgls1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbm985h5rp2qudnpgls1.png" alt="Terminal screenshot where the certificate file in /etc/letsencrypt/live expires Dec 26 2026 but the server still serves one expiring Oct 27, ssl-expiry-check.sh reports FAIL with 4 days left because the served certificate differs from the one on disk, and after systemctl reload nginx the served certificate and the check show Dec 26 and OK" width="799" height="219"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix for today is a reload. The fix for next time is a deploy hook, which certbot runs only after a successful renewal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;HOOK&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/sh
systemctl reload nginx
&lt;/span&gt;&lt;span class="no"&gt;HOOK
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a line for every service that uses the certificate, such as &lt;code&gt;systemctl reload postfix dovecot&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the renewal itself is failing
&lt;/h2&gt;

&lt;p&gt;If the file on disk is old too, the problem is earlier in the chain. Renewal is a short pipeline, and each stage has its own typical way of breaking:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9g0g7i7mssw9sn9mq1kw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9g0g7i7mssw9sn9mq1kw.png" alt="Diagram of the certificate renewal path in five stages, timer runs, challenge on port 80, new files under /etc/letsencrypt/live, deploy hook reload and served on port 443, each with the usual ways it breaks such as a closed port 80, a stale AAAA record, a full disk, no reload hook or a missing intermediate certificate" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start with these four commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; certbot
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot certificates
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50 /var/log/letsencrypt/letsencrypt.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first confirms the timer exists and ran recently (it's &lt;code&gt;certbot.timer&lt;/code&gt; from the Debian and Ubuntu packages, &lt;code&gt;snap.certbot.renew.timer&lt;/code&gt; from the snap). &lt;code&gt;certbot certificates&lt;/code&gt; lists every certificate it manages with its expiry date, and &lt;code&gt;--dry-run&lt;/code&gt; repeats the whole renewal against the staging servers without touching your real certificate.&lt;/p&gt;

&lt;p&gt;The dry run's error message is usually clear. The causes we run into most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port 80 is closed.&lt;/strong&gt; The HTTP challenge needs it, even if the site itself redirects everything to HTTPS. A firewall tightened after setup is the usual reason. If you followed our &lt;a href="https://blog.progineous.com/secure-linux-vps-15-minutes/" rel="noopener noreferrer"&gt;VPS security checklist&lt;/a&gt;, make sure &lt;code&gt;ufw allow 80/tcp&lt;/code&gt; is still there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS points somewhere else.&lt;/strong&gt; The certificate authority connects to whatever A and AAAA records say. A stale AAAA record pointing at an old server is a classic, because your own machine may never use IPv6 and you never notice. Our guide to &lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;reading dig output&lt;/a&gt; walks through checking both record types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The webroot moved.&lt;/strong&gt; After a site restructure, &lt;code&gt;--webroot -w&lt;/code&gt; can point at a folder the web server no longer serves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The disk is full.&lt;/strong&gt; Certbot can't write the new files, and the log says so plainly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick reference: what you see and where to look
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Check with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser says the date is invalid&lt;/td&gt;
&lt;td&gt;Renewal failed, or it worked and nothing reloaded&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-dates&lt;/code&gt; on the live connection and on the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Name mismatch error, often only on www&lt;/td&gt;
&lt;td&gt;Name missing from the certificate, or the wrong site answered&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-ext subjectAltName&lt;/code&gt;, and test with &lt;code&gt;-servername&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser fine, curl or an API client fails&lt;/td&gt;
&lt;td&gt;Intermediate certificate not sent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Verify return code&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correct on one network, old on another&lt;/td&gt;
&lt;td&gt;An old IP still answers through an AAAA or A record&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cert name ip&lt;/code&gt; against each address&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Put it on a schedule
&lt;/h2&gt;

&lt;p&gt;A check you run by hand is a check you forget. We wrapped everything above into one read-only script, &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/ssl/ssl-expiry-check.sh" rel="noopener noreferrer"&gt;ssl-expiry-check.sh&lt;/a&gt;, in our server toolkit on GitLab. It takes a list of hosts and reports days left, issuer, name match and chain status for each, and with &lt;code&gt;--live-dir /etc/letsencrypt/live&lt;/code&gt; it also flags a served certificate that differs from the one on disk. It exits non-zero when something needs attention, so a daily cron entry with &lt;code&gt;chronic&lt;/code&gt; only emails you on problems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;30 7 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;  chronic /usr/local/bin/ssl-expiry-check.sh &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/ssl-hosts.txt &lt;span class="nt"&gt;--live-dir&lt;/span&gt; /etc/letsencrypt/live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the warning at three weeks. With 90-day certificates renewing at 30 days left, a warning at 21 days means one renewal attempt has already been missed and you still have time to fix it calmly.&lt;/p&gt;

&lt;p&gt;The same idea applies to the domain itself, which can expire just as quietly as the certificate. Whether your sites live on shared hosting or on a &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; VPS, a five-minute check like this beats finding out from a customer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/check-ssl-certificate-expiry/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssl</category>
      <category>linux</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Check Whether Your Server Is Patched Against PHP2Shell</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Fri, 23 Oct 2026 02:27:07 +0000</pubDate>
      <link>https://dev.to/progineous/how-to-check-whether-your-server-is-patched-against-php2shell-58g2</link>
      <guid>https://dev.to/progineous/how-to-check-whether-your-server-is-patched-against-php2shell-58g2</guid>
      <description>&lt;p&gt;Two PHP vulnerabilities, nicknamed PHP2Shell, were disclosed on October 21, 2026. They are tracked as CVE-2026-109618 and CVE-2026-109619, and both carry a CVSS v4.0 score of 9.2. If your site or app runs on PHP, there is one practical question to answer today: is the PHP on your server actually patched?&lt;/p&gt;

&lt;p&gt;The answer is not always as obvious as reading a version number. This guide covers what was fixed, how to check your own setup in two commands, and what to do if the answer is no.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PHP2Shell is and which versions are fixed
&lt;/h2&gt;

&lt;p&gt;PHP2Shell is the name used for a pair of flaws in PHP itself, not in WordPress, a theme or a plugin. Public write-ups, including &lt;a href="https://blog.cloudflare.com/php-vulnerabilities-waf-protection/" rel="noopener noreferrer"&gt;Cloudflare's&lt;/a&gt;, describe them as memory handling bugs that can allow remote code execution, among other impacts. Because the problem sits in the language runtime, updating your CMS does not fix it. Only a patched PHP does.&lt;/p&gt;

&lt;p&gt;The PHP project released fixes in these versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP 8.5.12&lt;/li&gt;
&lt;li&gt;PHP 8.4.27&lt;/li&gt;
&lt;li&gt;PHP 8.3.36&lt;/li&gt;
&lt;li&gt;PHP 8.2.35&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHP 8.1 and older are end of life and get no fix from the PHP project. If you are still on one of those branches, the real answer is to move to a supported version, not to wait for a patch that is not coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we know about exploitation
&lt;/h2&gt;

&lt;p&gt;Reports differ a little, so here is the careful version. AWS, watchTowr, SecurityWeek and Sophos reported exploitation attempts in the wild. CISA said on October 21 that it had not observed exploitation. Both statements can be true at the same time, since attempts and confirmed successful attacks are not the same thing. Either way, a critical flaw with public details is worth closing quickly rather than debating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the version number can mislead you
&lt;/h2&gt;

&lt;p&gt;Most people run &lt;code&gt;php -v&lt;/code&gt;, see something like 8.3.x, and either panic or relax. Neither reaction is reliable. Linux distributions often backport a security fix into an older package and keep the same version number. A server can therefore be patched while still reporting a version that looks old, or look current while running a build from before the fix.&lt;/p&gt;

&lt;p&gt;That is why the build date matters. A patched build should show a build date of October 21, 2026 or later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your PHP version and build date
&lt;/h2&gt;

&lt;p&gt;Connect to your server over SSH and run these two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-v&lt;/span&gt;
php &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"build date"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first prints your PHP version. The second prints the build date. You can also create a temporary page that calls &lt;code&gt;phpinfo()&lt;/code&gt; and read the same field in your browser, then delete the page right away so it does not stay public.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9do90supk3qqybhoawuc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9do90supk3qqybhoawuc.png" alt="Terminal window showing the commands php -v and php -i | grep -i build date, with placeholder example output" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One catch: &lt;code&gt;php -v&lt;/code&gt; reports the command line PHP. Your website may be served by a different binary, such as PHP-FPM or an Apache module, and some servers have several PHP versions installed side by side. If the CLI looks fine, check &lt;code&gt;phpinfo()&lt;/code&gt; through the web server too, so you are looking at the PHP that actually handles your visitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you use shared or managed hosting
&lt;/h2&gt;

&lt;p&gt;You usually cannot update PHP yourself on these plans, and you do not need to. Your host manages the runtime. The right move is to check with your host, read their status page or announcement, and use the build date check above if they give you a way to view PHP info.&lt;/p&gt;

&lt;p&gt;Several providers have published their own notices about PHP2Shell. We list them here as references so you can see what a host announcement looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.hostinger.com/blog/protecting-php2shell/" rel="noopener noreferrer"&gt;Hostinger: protections against the PHP2Shell vulnerabilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.siteground.com/blog/critical-php-vulnerabilities-october-2026" rel="noopener noreferrer"&gt;SiteGround: critical PHP vulnerabilities, October 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wpenginestatus.com/incidents/969801" rel="noopener noreferrer"&gt;WP Engine: status incident page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.cpanel.net/hc/en-us/articles/44397389677335-Critical-PHP-Security-Release-CVE-2026-109618-and-CVE-2026-109619" rel="noopener noreferrer"&gt;cPanel: critical PHP security release&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.cloudflare.com/php-vulnerabilities-waf-protection/" rel="noopener noreferrer"&gt;Cloudflare: WAF protection for the PHP vulnerabilities&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A WAF rule is a useful extra layer, but it is not a replacement for a patched PHP. Keep it enabled while you update, not instead of updating.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run your own VPS or server
&lt;/h2&gt;

&lt;p&gt;On a self-managed server, nobody will patch PHP for you. The steps below are typical Debian and Ubuntu commands. Adjust them for your distribution and your setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart the PHP service so the new binaries are actually loaded. Replace 8.4 with the version you run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart php8.4-fpm
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the PHP-FPM line if your web server talks to PHP-FPM, and the Apache line if you run PHP as an Apache module. If you use nginx with PHP-FPM, restarting PHP-FPM is the part that matters.&lt;/p&gt;

&lt;p&gt;A few honest caveats. If your server uses a third party repository for PHP, such as Ondrej Sury's packages (the PPA on Ubuntu or packages.sury.org), the exact fixed versions may only arrive through that repository. Backported builds may keep the old version number, which is again why the build date is the check to trust. If you are on PHP 8.1 or older, plan an upgrade to a supported branch and test your application on it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjmhm6r98z3eax1bm7om7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjmhm6r98z3eax1bm7om7.png" alt="Checklist graphic: check version, check build date, update packages, restart PHP service, check again" width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;php -v&lt;/code&gt; and note your version and whether it is on a supported branch.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;php -i | grep -i "build date"&lt;/code&gt; and look for October 21, 2026 or later.&lt;/li&gt;
&lt;li&gt;Update your packages, or ask your host to confirm they have done it.&lt;/li&gt;
&lt;li&gt;Restart PHP-FPM or Apache so the update takes effect.&lt;/li&gt;
&lt;li&gt;Check again, including &lt;code&gt;phpinfo()&lt;/code&gt; for the PHP your website really uses.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to confirm it worked
&lt;/h2&gt;

&lt;p&gt;After the update and the restart, repeat the checks. You want to see the build date of October 21, 2026 or later, and a version that matches or exceeds the fixed releases for your branch where the package carries the upstream number. If the build date is still old, the update did not reach that binary. Look for a second PHP installation, a missing repository, or a service that was not restarted.&lt;/p&gt;

&lt;p&gt;We have already updated PHP on our servers at &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt;. If you run a self-managed VPS (plans start from $6.96/mo), PHP on that machine is yours to update, so the steps above apply to you.&lt;/p&gt;

&lt;p&gt;Whatever your setup, the whole check takes a couple of minutes. Run the two commands, read the build date, and you will know where you stand.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>Disk Full on a Linux VPS? How to Find What Is Using the Space</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:08:16 +0000</pubDate>
      <link>https://dev.to/progineous/disk-full-on-a-linux-vps-how-to-find-what-is-using-the-space-305</link>
      <guid>https://dev.to/progineous/disk-full-on-a-linux-vps-how-to-find-what-is-using-the-space-305</guid>
      <description>&lt;p&gt;A full disk rarely announces itself. MySQL stops accepting writes, WordPress can't upload an image, &lt;code&gt;apt&lt;/code&gt; fails halfway through an upgrade, or you get "No space left on device" from a command that worked yesterday. On a 20 or 30 GB VPS, one chatty log or a forgotten backup folder is enough.&lt;/p&gt;

&lt;p&gt;The fix is almost never "delete random big things until it works". It's a short triage: confirm what kind of full you are, find where the space went, clear the parts that are safe to clear, and leave the rest alone. Below is the order we work through, with the commands and real output from a test server in the same state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: buy a little room first
&lt;/h2&gt;

&lt;p&gt;At 100%, some tools won't even start because they can't write a temp file. Two commands are safe on almost any Debian or Ubuntu server and usually free a few hundred megabytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get clean
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;--vacuum-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first removes downloaded &lt;code&gt;.deb&lt;/code&gt; files, which apt can fetch again. The second trims old journal files down to 200 MB. Neither touches your site, database or config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: is it space or inodes?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;df -h&lt;/code&gt; shows blocks, which is what people usually mean by disk space. &lt;code&gt;df -i&lt;/code&gt; shows inodes, one per file. A disk can run out of inodes while plenty of gigabytes are free, usually because something writes millions of tiny files: PHP sessions, a cache plugin, a stuck mail queue. If &lt;code&gt;IUse%&lt;/code&gt; is the one near 100%, look for directories with huge file counts (&lt;code&gt;du --inodes -x / | sort -n | tail&lt;/code&gt;) instead of big files.&lt;/p&gt;

&lt;p&gt;Also notice that Size minus Used doesn't equal Avail. On ext4, 5% of the filesystem is reserved for root by default, so the system can still log in and write a few things after normal users hit zero. On the test server below that's about 1.5 GB of a 30 GB disk. You can lower it with &lt;code&gt;tune2fs -m&lt;/code&gt;, but on a root filesystem that margin is what keeps a full server reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: walk down from / with du
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-xh&lt;/span&gt; &lt;span class="nt"&gt;--max-depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 / | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-x&lt;/code&gt; flag keeps &lt;code&gt;du&lt;/code&gt; on the root filesystem, so it skips &lt;code&gt;/proc&lt;/code&gt; and other mounts. &lt;code&gt;sort -h&lt;/code&gt; understands the K, M and G suffixes, so the biggest directory lands at the bottom. Then repeat one level deeper on whatever is biggest, usually &lt;code&gt;/var&lt;/code&gt; or &lt;code&gt;/home&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F02rvw7u0a1fvu865490b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F02rvw7u0a1fvu865490b.png" alt="Terminal screenshot of df -h showing the root disk 99% full with 345M available, df -i at 9% inode use, and du drilling down from / to /var and /var/lib, where Docker takes 9.1G" width="800" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;ncdu&lt;/code&gt; is installed (&lt;code&gt;apt install ncdu&lt;/code&gt;), &lt;code&gt;sudo ncdu -x /&lt;/code&gt; does the same walk interactively with the arrow keys. Plain &lt;code&gt;du&lt;/code&gt; works everywhere, though.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: look for single big files
&lt;/h2&gt;

&lt;p&gt;Directories tell you where. A file search tells you what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;find / &lt;span class="nt"&gt;-xdev&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-size&lt;/span&gt; +500M &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our test server that turned up the usual crowd: a 1.5 GB database dump in &lt;code&gt;/root&lt;/code&gt; from before an upgrade, an old site zip, nine weekly backup archives at 1.2 GB each, a 940 MB nginx access log, and a 3.6 GB file deep inside &lt;code&gt;/var/lib/docker/containers&lt;/code&gt;. That last one gets its own section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: check the usual suspects
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The systemd journal.&lt;/strong&gt; By default journald may use up to 10% of the filesystem, capped at 4 GB. Check it, trim it, then set a permanent limit with &lt;code&gt;SystemMaxUse=500M&lt;/code&gt; in &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt; and &lt;code&gt;systemctl restart systemd-journald&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ journalctl --disk-usage
Archived and active journals take up 1.3G in the file system.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Package cache and old kernels.&lt;/strong&gt; &lt;code&gt;du -sh /var/cache/apt/archives&lt;/code&gt; showed 205M here. &lt;code&gt;apt-get clean&lt;/code&gt; handles that. On Ubuntu, old kernels add a few hundred megabytes each in &lt;code&gt;/boot&lt;/code&gt; and &lt;code&gt;/usr/lib/modules&lt;/code&gt;. &lt;code&gt;sudo apt autoremove --purge&lt;/code&gt; removes the ones no longer needed and keeps the running one. Read the list before you type yes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker.&lt;/strong&gt; Start with &lt;code&gt;docker system df&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          10        7         5.285GB   1.922GB (36%)
Containers      7         5         1.099kB   0B (0%)
Local Volumes   4         4         243.5MB   0B (0%)
Build Cache     0         0         0B        0B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things this table doesn't tell you. Old stopped containers keep their images "active", so check &lt;code&gt;docker ps -a&lt;/code&gt; and remove the ones you really don't need before &lt;code&gt;docker image prune -a&lt;/code&gt;. And container logs aren't counted at all. That 3.6 GB file was the JSON log of one busy worker container. Cap logs for new containers in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"50m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Existing containers keep their old settings until you recreate them. And be careful with &lt;code&gt;docker volume prune&lt;/code&gt;. Volumes are where your database lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs that never rotate.&lt;/strong&gt; An &lt;code&gt;access.log.1&lt;/code&gt; the size of the current log means rotation runs but compression doesn't, or the app writes somewhere logrotate doesn't know about. &lt;code&gt;sudo logrotate -d /etc/logrotate.conf&lt;/code&gt; does a dry run and shows what it would do for each file without changing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: when df and du disagree
&lt;/h2&gt;

&lt;p&gt;Look back at the first screenshot. &lt;code&gt;du&lt;/code&gt; counts 27G, while &lt;code&gt;df&lt;/code&gt; says 28G is used. When the gap is bigger than rounding, look for a file that was deleted while a process still had it open. The name is gone, but the blocks stay allocated until the process closes the file. The classic way to get there is &lt;code&gt;rm&lt;/code&gt; on a big log an app is still writing to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lsof &lt;span class="nt"&gt;-nP&lt;/span&gt; +L1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+L1&lt;/code&gt; lists open files with a link count below one, meaning deleted. Without lsof, &lt;code&gt;ls -l /proc/*/fd 2&amp;gt;/dev/null | grep deleted&lt;/code&gt; finds the same thing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffwd0fws59rx1u5qb68e4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffwd0fws59rx1u5qb68e4.png" alt="Terminal screenshot where df reports 28G used but du counts 27G, lsof +L1 finds a 900 MB deleted worker.log still held open by a python3 process, and emptying it through /proc brings the disk back to 96%" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Restarting the service that holds the file releases the space. If you can't restart it right now, empty the file through its descriptor, as in the screenshot. Next time, empty a live log with &lt;code&gt;truncate -s 0 file&lt;/code&gt; instead of deleting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to delete
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4lqir9d5rtjb1r4wwc9e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4lqir9d5rtjb1r4wwc9e.png" alt="Checklist for cleaning a full disk in three columns: safe to clear (apt cache, old journal files, old kernels, unused Docker images), check first (backups, live logs, stopped containers, deleted open files) and leave alone (database folders, /var/lib/docker, Docker volumes, /boot)" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things look like junk and aren't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything under &lt;code&gt;/var/lib/mysql&lt;/code&gt; or &lt;code&gt;/var/lib/postgresql&lt;/code&gt;.&lt;/strong&gt; Binary logs and WAL files get big, but you clear them from inside the database (&lt;code&gt;PURGE BINARY LOGS&lt;/code&gt; in MySQL, fixing a stuck replication slot in PostgreSQL), never with &lt;code&gt;rm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/var/lib/docker&lt;/code&gt; by hand.&lt;/strong&gt; Use the docker commands. Deleting overlay2 folders directly breaks images and containers in ways that are painful to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files in &lt;code&gt;/boot&lt;/code&gt;.&lt;/strong&gt; Remove kernels with apt so the bootloader config is updated too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your only copy of a backup.&lt;/strong&gt; Those weekly archives belong on another machine anyway. Copy them off the server first, check that the copy opens, then delete the old ones. Our guide on &lt;a href="https://blog.progineous.com/how-to-back-up-a-vps/" rel="noopener noreferrer"&gt;backing up a VPS and testing the restore&lt;/a&gt; covers where to send them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keep it from happening again
&lt;/h2&gt;

&lt;p&gt;Once there's room, put the limits in place: &lt;code&gt;SystemMaxUse&lt;/code&gt; for the journal, &lt;code&gt;log-opts&lt;/code&gt; for Docker, and a logrotate entry for every app that writes its own log. Add a disk check to whatever monitoring you already run, alerting at 80% rather than 95%. If you set up the server with our &lt;a href="https://blog.progineous.com/secure-linux-vps-15-minutes/" rel="noopener noreferrer"&gt;15-minute VPS security checklist&lt;/a&gt;, unattended-upgrades is already installing new kernels, so run &lt;code&gt;apt autoremove --purge&lt;/code&gt; now and then.&lt;/p&gt;

&lt;p&gt;We put the read-only checks from this article into one script, &lt;a href="https://gitlab.com/pro-gineous-group/server-toolkit/-/blob/main/disk/disk-usage-report.sh" rel="noopener noreferrer"&gt;disk-usage-report.sh&lt;/a&gt;, in our server toolkit on GitLab. It prints suggested cleanup commands and deletes nothing.&lt;/p&gt;

&lt;p&gt;And if the disk keeps filling because the site has simply grown, a bigger disk is cheaper than cleaning up every week. &lt;a href="https://progineous.com/?utm_source=devto" rel="noopener noreferrer"&gt;Pro Gineous&lt;/a&gt; VPS plans start at $6.96/mo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-back-up-a-vps/" rel="noopener noreferrer"&gt;How to Back Up a VPS and Prove the Restore Works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/secure-linux-vps-15-minutes/" rel="noopener noreferrer"&gt;Secure a Linux VPS in 15 Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-set-up-a-vps/" rel="noopener noreferrer"&gt;Setting Up a New VPS: The First-Boot Checklist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-choose-a-vps-plan/" rel="noopener noreferrer"&gt;Choosing a VPS Plan: Start with Memory&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/vps-disk-full/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>docker</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Using RDAP to Check a Domain's Expiry Date and Lock Status</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:11:31 +0000</pubDate>
      <link>https://dev.to/progineous/using-rdap-to-check-a-domains-expiry-date-and-lock-status-4pge</link>
      <guid>https://dev.to/progineous/using-rdap-to-check-a-domains-expiry-date-and-lock-status-4pge</guid>
      <description>&lt;p&gt;Every domain has a record at its registry that says when it expires, which locks are on it, which registrar controls it and which nameservers it hands visitors to. For years you read that record with &lt;code&gt;whois&lt;/code&gt;, and every registry formatted the answer a little differently. That job now belongs to RDAP, the Registration Data Access Protocol. Since January 28, 2025, ICANN treats RDAP as the definitive source of registration data for generic TLDs, and most gTLD registries and registrars no longer have to run WHOIS at all (.com, .name and .post are the exceptions for now).&lt;/p&gt;

&lt;p&gt;The nice part, if you look after more than a couple of domains: RDAP is plain HTTPS that returns JSON. You can query it with &lt;code&gt;curl&lt;/code&gt;, filter it with &lt;code&gt;jq&lt;/code&gt;, and turn it into an expiry check that runs from cron. Below is how to run an RDAP lookup from the terminal, read what comes back, make sense of the status values, and set up a small script that warns you before a renewal slips.&lt;/p&gt;

&lt;h2&gt;
  
  
  One RDAP lookup, nothing new to install
&lt;/h2&gt;

&lt;p&gt;An RDAP server answers at a predictable path: its base URL, then &lt;code&gt;domain/&lt;/code&gt;, then the name. For .com that server belongs to Verisign:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://rdap.verisign.com/com/v1/domain/example.com | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't know which server handles a TLD, the public redirector at rdap.org looks it up and sends you there with a 302, so &lt;code&gt;curl -sL https://rdap.org/domain/example.com&lt;/code&gt; works for any TLD it knows. ICANN offers the same lookup in a browser at lookup.icann.org.&lt;/p&gt;

&lt;p&gt;The full reply is long and mostly notices and links. The parts you care about fit in one &lt;code&gt;jq&lt;/code&gt; filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://rdap.verisign.com/com/v1/domain/example.com | jq &lt;span class="s1"&gt;'{
  name: .ldhName,
  status,
  events: ([.events[] | {(.eventAction): .eventDate}] | add),
  nameservers: [.nameservers[].ldhName],
  dnssec: .secureDNS.delegationSigned
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1eaqyvome7g3a798mwv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1eaqyvome7g3a798mwv9.png" alt="Terminal screenshot of an RDAP lookup: the IANA bootstrap file returning Verisign's RDAP base URL for .com, then a curl and jq query for example.com showing client delete, transfer and update prohibited statuses, a 1995 registration date, a 2027-08-13 expiration date, two Cloudflare nameservers and DNSSEC enabled, followed by example.org returning the three server prohibited statuses" width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the server address comes from
&lt;/h2&gt;

&lt;p&gt;Hard-coding Verisign works for .com and .net. The proper way to find a server is the IANA bootstrap file at &lt;code&gt;https://data.iana.org/rdap/dns.json&lt;/code&gt;, which maps each TLD to its RDAP base URL. It's the same file rdap.org and ICANN's lookup read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://data.iana.org/rdap/dns.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.services[] | select(.[0] | index("com")) | .[1][0]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints &lt;code&gt;https://rdap.verisign.com/com/v1/&lt;/code&gt;. Every gTLD is in the file, plus a growing number of country-code TLDs, but not all of them. When we checked this week, .uk was listed while .de, .io and .co were not. Country-code registries aren't bound by ICANN's contracts, so for those you still use the registry's own WHOIS or web lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the reply
&lt;/h2&gt;

&lt;p&gt;Take the fields in the order you'd check them when something has gone wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;events&lt;/strong&gt; is a list of dated actions. &lt;code&gt;registration&lt;/code&gt; is when the name was first created, &lt;code&gt;expiration&lt;/code&gt; is the date the registry has on file, and &lt;code&gt;last changed&lt;/code&gt; is the most recent update to the domain itself, such as new nameservers, a status change or a renewal. Every date is in UTC. &lt;code&gt;last update of RDAP database&lt;/code&gt; only tells you how fresh the answer is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;status&lt;/strong&gt; uses the same values registrars and registries exchange over EPP, written out in plain words. &lt;code&gt;client transfer prohibited&lt;/code&gt; is the transfer lock your registrar sets, the one you toggle in its panel. The &lt;code&gt;server ...&lt;/code&gt; versions come from the registry, and only the registry can lift them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nameservers&lt;/strong&gt; is the delegation, the servers the TLD sends resolvers to. If this list doesn't match your DNS provider, nothing you edit in that provider's zone will ever be seen. It's the same mismatch that &lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;dig +trace exposes&lt;/a&gt;, viewed from the registry's side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;secureDNS&lt;/strong&gt; says whether a DS record is published. &lt;code&gt;delegationSigned: true&lt;/code&gt; means resolvers will validate DNSSEC for the zone. If you're moving DNS to a new provider and this says true, sort out the DS record before you switch nameservers, or the domain fails validation the moment the change lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;entities&lt;/strong&gt; holds the registrar's name and IANA ID, plus an abuse contact. Registrant details are usually missing or marked as redacted. That's privacy policy at work, not a broken lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registry answer or registrar answer
&lt;/h2&gt;

&lt;p&gt;For .com and .net the registry holds only part of the record. The rest sits with the registrar, and the registry reply points there with a link of type &lt;code&gt;related&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://rdap.verisign.com/com/v1/domain/example.com &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.links[] | select(.rel == "related") | .href'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;example.com is a reserved name held by IANA, so it has no such link, but a normally registered .com returns a URL on its registrar's own RDAP server. Query that one as well when you want the registrar's view.&lt;/p&gt;

&lt;p&gt;It matters most around expiry. When a .com reaches its expiration date, the registry renews it automatically for a year, and the registrar then has a grace window of up to 45 days to keep it or delete it. So the registry's date can jump a year ahead even though nobody has paid. If the status list includes &lt;code&gt;auto renew period&lt;/code&gt;, don't trust that date. Log in to your registrar account and check that the renewal really went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status values worth a second look
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjitykoqxxzjrnjdgbkr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjitykoqxxzjrnjdgbkr.png" alt="Flat diagram grouping RDAP domain status values into three columns: the usual registrar lock (client transfer, update and delete prohibited, plus active), the registry lock that only the registry can lift (server transfer, update and delete prohibited), and statuses that need action now (client or server hold, pending transfer, auto renew period, redemption period and pending delete)" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most domains show some mix of the three &lt;code&gt;client ... prohibited&lt;/code&gt; values. That's the normal registrar lock, and it's what you want on a name you aren't moving. These are the ones that mean something is happening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;client hold&lt;/code&gt; or &lt;code&gt;server hold&lt;/code&gt;&lt;/strong&gt;: the domain is pulled from the zone and doesn't resolve at all. Registrars set it for things like an unpaid renewal or a registrant email that was never verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pending transfer&lt;/code&gt;&lt;/strong&gt;: a move to another registrar is in progress. If nobody on your side asked for one, call your registrar today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redemption period&lt;/code&gt;&lt;/strong&gt;: the domain was deleted and can still be restored, usually for a much higher fee than a renewal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pending delete&lt;/code&gt;&lt;/strong&gt;: redemption is over. The name gets released within days and can't be renewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;server ... prohibited&lt;/code&gt;&lt;/strong&gt; on its own: a registry-level lock. It's either a paid registry lock or a name the registry is protecting, which is why example.org carries all three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;active&lt;/code&gt;&lt;/strong&gt;: no statuses at all. Fine for a moment during a transfer, but on a name you're keeping it means only the auth code stands between the domain and a transfer request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're about to move a domain, &lt;a href="https://blog.progineous.com/how-to-unlock-a-domain/" rel="noopener noreferrer"&gt;unlocking a domain before a transfer&lt;/a&gt; covers which of these you can clear yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small expiry check for cron
&lt;/h2&gt;

&lt;p&gt;Registrars send renewal reminders, but they go to whatever email was on the account when it was opened. A script that reads the registry directly doesn't care about a dead mailbox. This one takes a file with one domain per line, finds each TLD's server in the bootstrap file, and prints the days left. It exits with 1 when anything needs a look, so it slots into cron or a monitoring check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Usage: domain-expiry.sh domains.txt [warn_days]&lt;/span&gt;
&lt;span class="c"&gt;# Needs curl, jq and GNU date.&lt;/span&gt;
&lt;span class="nv"&gt;list&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;30&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nv"&gt;boot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; https://data.iana.org/rdap/dns.json&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; d&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;tld&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="p"&gt;##*.&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; t &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tld&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'first(.services[] | select(.[0] | index($t)) | .[1][0]) // empty'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$boot&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-22s no RDAP server for .%s, check by hand\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tld&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n%{http_code}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="p"&gt;%/&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/domain/&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="p"&gt;##*&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; 200 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-22s lookup failed: HTTP %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nv"&gt;exp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'first(.events[] | select(.eventAction=="expiration") | .eventDate) // empty'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[.status[] | select(test("hold|redemption|pending delete|auto renew period"))] | join(", ")'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$exp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-22s no expiration date in the RDAP reply\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue
  fi
  &lt;/span&gt;&lt;span class="nv"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$exp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

  &lt;span class="nv"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ok
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$days&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$warn&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"RENEW SOON"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$flags&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CHECK: &lt;/span&gt;&lt;span class="nv"&gt;$flags&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-22s expires %s  %4d days  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;%%T*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$days&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$note&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$list&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$rc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9drv5ldoxmstxvjqziym.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9drv5ldoxmstxvjqziym.png" alt="Terminal screenshot of the domain-expiry.sh script checking five domains: example.com, example.net and example.org show their expiry dates and days left as ok, example.de has no RDAP server in the IANA bootstrap file, a misspelled domain returns HTTP 404, the script exits with status 1, and a second run with a 300-day threshold flags example.com as RENEW SOON" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few notes on what it does and why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It sleeps a second between lookups.&lt;/strong&gt; Registries rate-limit RDAP, and a burst of queries can earn you HTTP 429 replies instead of data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 404 means the registry has no such name.&lt;/strong&gt; In a list of domains you own, that's either a typo or a domain that's already gone. If it's gone, run a &lt;a href="https://progineous.com/domain-search.php?utm_source=devto" rel="noopener noreferrer"&gt;domain search&lt;/a&gt; right away to see whether you can register it again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLDs missing from the bootstrap file get reported&lt;/strong&gt;, not skipped silently, so a .de name doesn't fall off your radar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It uses GNU &lt;code&gt;date -d&lt;/code&gt;.&lt;/strong&gt; On macOS, install coreutils and swap in &lt;code&gt;gdate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To run it every Monday and only hear about problems, wrap it in &lt;code&gt;chronic&lt;/code&gt; from the moreutils package, which stays quiet unless the command exits with an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAILTO=ops@example.com
0 8 * * 1  chronic /usr/local/bin/domain-expiry.sh /etc/domains.txt 45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 45-day threshold leaves room to fix a declined card or a dead login before anything breaks. Want to see the warning fire once? Pass a big number, such as 300, like the second run in the screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RDAP can't tell you
&lt;/h2&gt;

&lt;p&gt;RDAP shows what the registry knows. It can't tell you whether the card on file still works, whether auto-renew is switched on, or who at your company holds the registrar login. Those live in the registrar account, and they're covered in &lt;a href="https://blog.progineous.com/how-to-renew-a-domain-name/" rel="noopener noreferrer"&gt;renewing a domain name before it expires&lt;/a&gt;. Keep the script running next to that checklist, not instead of it.&lt;/p&gt;

&lt;p&gt;If the audit turns up names scattered across old registrar accounts, you can bring them under one login with a &lt;a href="https://progineous.com/transfer-domain.php?utm_source=devto" rel="noopener noreferrer"&gt;domain transfer to Pro Gineous&lt;/a&gt;, and every lookup above works the same afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-unlock-a-domain/" rel="noopener noreferrer"&gt;Unlocking a Domain Before a Transfer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-renew-a-domain-name/" rel="noopener noreferrer"&gt;Renewing a Domain Name Before It Expires&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/what-to-check-after-a-transfer/" rel="noopener noreferrer"&gt;What to Check After a Domain Transfer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;How to Read dig Output When DNS Looks Wrong&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/rdap-domain-lookup/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>domains</category>
      <category>dns</category>
      <category>devops</category>
      <category>bash</category>
    </item>
    <item>
      <title>How to Read DMARC Reports Before You Move to p=reject</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:12:55 +0000</pubDate>
      <link>https://dev.to/progineous/how-to-read-dmarc-reports-before-you-move-to-preject-g35</link>
      <guid>https://dev.to/progineous/how-to-read-dmarc-reports-before-you-move-to-preject-g35</guid>
      <description>&lt;p&gt;Most domains that have DMARC at all are sitting on &lt;code&gt;p=none&lt;/code&gt;. Someone added the record while setting up email, pointed &lt;code&gt;rua=&lt;/code&gt; at a mailbox, and the reports have been arriving every day since: small zip and gzip attachments from Google, Outlook and other receivers, usually unopened.&lt;/p&gt;

&lt;p&gt;Those files answer the one question you need settled before tightening the policy: who is sending mail with your domain in the From line, and would that mail survive &lt;code&gt;p=quarantine&lt;/code&gt; or &lt;code&gt;p=reject&lt;/code&gt;? Below is how to read them with nothing more than &lt;code&gt;dig&lt;/code&gt;, &lt;code&gt;unzip&lt;/code&gt; and a small XML tool, and how to move up the policy ladder without losing invoices or password resets on the way. If SPF and DKIM aren't set up yet, start with &lt;a href="https://blog.progineous.com/how-to-set-up-business-email/" rel="noopener noreferrer"&gt;how to set up business email on your own domain&lt;/a&gt; and come back once those records exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, confirm what you've published
&lt;/h2&gt;

&lt;p&gt;Reports describe how receivers judged your mail against your DNS records, so read those records the way a receiver would. Three lookups cover it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT _dmarc.example.com
dig +short TXT example.com
dig +short TXT s1._domainkey.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuqc8x72vfnaf8yici2yu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuqc8x72vfnaf8yici2yu.png" alt="Terminal screenshot of three dig lookups: the DMARC record at _dmarc.example.com showing v=DMARC1 with p=none and a rua reporting address, the SPF record for example.com ending in -all next to a site verification TXT record, and a 2048-bit DKIM public key for selector s1 split into two quoted strings" width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The DMARC record lives at the &lt;code&gt;_dmarc&lt;/code&gt; label, not on the domain itself. &lt;code&gt;p=none&lt;/code&gt; means "report, but don't act", and &lt;code&gt;rua=&lt;/code&gt; is where aggregate reports go. If that address is on a different domain, such as a reporting service, the other domain has to publish a small authorization record (&lt;code&gt;example.com._report._dmarc.reports.example.net&lt;/code&gt; with the value &lt;code&gt;v=DMARC1&lt;/code&gt;). Without it, receivers quietly skip sending.&lt;/p&gt;

&lt;p&gt;On the domain itself, look for exactly one TXT record starting with &lt;code&gt;v=spf1&lt;/code&gt;. Two of them make SPF return a permanent error, which counts as a fail for DMARC. Other TXT records, like site verification strings, can sit alongside it.&lt;/p&gt;

&lt;p&gt;DKIM is the awkward one, because there's no way to list a domain's keys. You need the selector, which is the &lt;code&gt;s=&lt;/code&gt; tag in the &lt;code&gt;DKIM-Signature&lt;/code&gt; header of any message you've sent (use "show original" or "view source" in your mail client). Your mailbox provider uses one selector, and every other service that signs as your domain uses its own. A 2048-bit key is longer than the 255-character limit for a single TXT string, so &lt;code&gt;dig&lt;/code&gt; prints it as two quoted chunks. That's normal. Receivers join them back together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open one report
&lt;/h2&gt;

&lt;p&gt;Reports arrive as attachments named after the receiver, your domain and the time window they cover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google.com!example.com!1792281600!1792367999.zip
enterprise.protection.outlook.com!example.com!1792281600!1792368000.xml.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two long numbers are the start and end of the window in Unix time. &lt;code&gt;date -u -d @1792281600&lt;/code&gt; turns the first one into midnight UTC on October 18. Some receivers zip the file and others gzip it, so &lt;code&gt;unzip&lt;/code&gt; or &lt;code&gt;gunzip&lt;/code&gt; gets you to the XML. After a short header naming the reporter and repeating the policy it found, there's one &lt;code&gt;&amp;lt;record&amp;gt;&lt;/code&gt; per combination of sending IP and result. This one deserves a close look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;record&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;row&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;source_ip&amp;gt;&lt;/span&gt;198.51.100.24&lt;span class="nt"&gt;&amp;lt;/source_ip&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;count&amp;gt;&lt;/span&gt;212&lt;span class="nt"&gt;&amp;lt;/count&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;policy_evaluated&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;disposition&amp;gt;&lt;/span&gt;none&lt;span class="nt"&gt;&amp;lt;/disposition&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;dkim&amp;gt;&lt;/span&gt;fail&lt;span class="nt"&gt;&amp;lt;/dkim&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;spf&amp;gt;&lt;/span&gt;fail&lt;span class="nt"&gt;&amp;lt;/spf&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/policy_evaluated&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/row&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;identifiers&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;header_from&amp;gt;&lt;/span&gt;example.com&lt;span class="nt"&gt;&amp;lt;/header_from&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/identifiers&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;auth_results&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dkim&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;domain&amp;gt;&lt;/span&gt;mailer.example.net&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;result&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/result&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;selector&amp;gt;&lt;/span&gt;m1&lt;span class="nt"&gt;&amp;lt;/selector&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dkim&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;spf&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;domain&amp;gt;&lt;/span&gt;bounces.mailer.example.net&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;result&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/result&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/spf&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/auth_results&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/record&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it in this order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source_ip&lt;/code&gt; and &lt;code&gt;count&lt;/code&gt;:&lt;/strong&gt; 212 messages from one IP, all showing &lt;code&gt;example.com&lt;/code&gt; in the From header (&lt;code&gt;header_from&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;auth_results&lt;/code&gt;:&lt;/strong&gt; the raw checks. DKIM passed and so did SPF. On their own, they look fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;policy_evaluated&lt;/code&gt;:&lt;/strong&gt; DMARC's verdict, and both say &lt;code&gt;fail&lt;/code&gt;. DMARC only counts a pass when the domain that passed matches the From domain. DKIM was signed by &lt;code&gt;mailer.example.net&lt;/code&gt; and SPF checked &lt;code&gt;bounces.mailer.example.net&lt;/code&gt;. Neither is &lt;code&gt;example.com&lt;/code&gt;, so neither is aligned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;disposition&lt;/code&gt;:&lt;/strong&gt; what the receiver actually did. &lt;code&gt;none&lt;/code&gt;, because that's what the policy asked for. Under &lt;code&gt;p=reject&lt;/code&gt;, those 212 messages would have bounced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Passing in &lt;code&gt;auth_results&lt;/code&gt; but failing in &lt;code&gt;policy_evaluated&lt;/code&gt; is the pattern you'll see most, and it's nearly always a legitimate service you forgot about, not an attacker. Reports that follow the updated aggregate reporting spec (RFC 9990) may add an &lt;code&gt;xmlns&lt;/code&gt; attribute and a few extra fields, but the record layout is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn a folder of reports into a sender list
&lt;/h2&gt;

&lt;p&gt;One report is readable by eye. A month of them from a dozen receivers isn't, so pull out the useful columns with &lt;code&gt;xmlstarlet&lt;/code&gt; (&lt;code&gt;apt install xmlstarlet&lt;/code&gt; on Debian and Ubuntu, &lt;code&gt;brew install xmlstarlet&lt;/code&gt; on a Mac):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip &lt;span class="s1"&gt;'google.com!example.com!1792281600!1792367999.zip'&lt;/span&gt;
&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="s1"&gt;'enterprise.protection.outlook.com!example.com!1792281600!1792368000.xml.gz'&lt;/span&gt;
xmlstarlet sel &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; //record &lt;span class="nt"&gt;-v&lt;/span&gt; row/source_ip &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; row/count &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;' dkim='&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; row/policy_evaluated/dkim &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;' spf='&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; row/policy_evaluated/spf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;' signer='&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; auth_results/dkim/domain &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.xml | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask DNS who each unfamiliar IP belongs to with a reverse lookup, for example &lt;code&gt;dig +short -x 198.51.100.24&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzyl6vv45gmoghuqthnzu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzyl6vv45gmoghuqthnzu.png" alt="Terminal screenshot of extracting DMARC aggregate reports with unzip and gunzip, then an xmlstarlet one-liner listing each sending IP with its message count, aligned DKIM and SPF results and DKIM signer, followed by reverse DNS lookups that identify a newsletter platform, the company web server and an IP with no reverse DNS" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every row lands in one of four buckets, and each bucket has its own fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your mailbox provider&lt;/strong&gt; (192.0.2.25 here). DKIM and SPF are both aligned. Nothing to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A service sending on your behalf&lt;/strong&gt; (198.51.100.24, which reverse-resolves to a newsletter platform). It passes, but for the wrong domain. Most of these services have a "domain authentication" or "custom sending domain" setting that hands you one or two CNAME records, such as &lt;code&gt;m1._domainkey.example.com&lt;/code&gt; pointing at their key. Once they sign with &lt;code&gt;d=example.com&lt;/code&gt;, DKIM aligns and DMARC passes. Aligning SPF too, with a custom return-path on a subdomain, is nice to have but not required. One aligned pass is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own servers&lt;/strong&gt; (203.0.113.80, the web server). A contact form or shop sending straight from PHP, with no DKIM signature and an IP that isn't in SPF. Resist the urge to just add the IP to SPF. Send that mail through your mailbox provider's SMTP submission port with a real account instead, so it gets the same aligned signature as everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody you recognize&lt;/strong&gt; (203.0.113.199, no reverse DNS, nine messages). That's someone using your domain without permission, and it's exactly the mail &lt;code&gt;p=reject&lt;/code&gt; exists to stop. Leave it failing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Expect a fuzzy fifth group as well: forwarded mail and mailing lists. A list that adds a footer breaks the DKIM signature, and a forwarder's IP was never in your SPF, so a small trickle of failures from university or ISP ranges is normal and can't be fixed from your side. Some large receivers use ARC to give that mail the benefit of the doubt. Don't chase it.&lt;/p&gt;

&lt;p&gt;When the volume outgrows a shell one-liner, open-source parsers like parsedmarc can load reports into a dashboard, and hosted report services do the same job. The four buckets stay the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tighten in steps, not in one edit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgicc8arokbo55fupy1vh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgicc8arokbo55fupy1vh.png" alt="Flat diagram of a safe DMARC rollout in three stages, from p=none for monitoring to p=quarantine to p=reject, with the check to pass before each step: every legitimate sender aligned in the reports, nothing legitimate quarantined, and a lowered TTL on the _dmarc record so the change can be rolled back" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once every legitimate source in the last few weeks of reports is aligned, raise the policy one level at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com
v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older guides ramp up with &lt;code&gt;pct=10&lt;/code&gt;, then &lt;code&gt;pct=25&lt;/code&gt;, and so on. RFC 9989, the DMARC standard published in May 2026, retires &lt;code&gt;pct&lt;/code&gt; because receivers applied the in-between values inconsistently. Its replacement is &lt;code&gt;t=y&lt;/code&gt;, which asks receivers to apply one level below what you published, so &lt;code&gt;p=reject; t=y&lt;/code&gt; behaves like quarantine at receivers that support it. Plain steps work everywhere: stay on quarantine for a couple of weeks, confirm that nothing legitimate shows up with a &lt;code&gt;quarantine&lt;/code&gt; disposition, then move to reject.&lt;/p&gt;

&lt;p&gt;A few habits keep each change uneventful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;rua=&lt;/code&gt; for good.&lt;/strong&gt; Reports are how you'll spot the next forgotten sender, like a helpdesk or billing tool someone signs up for next year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower the TTL on the &lt;code&gt;_dmarc&lt;/code&gt; record&lt;/strong&gt; to 300 or 600 seconds before each change, so stepping back takes minutes rather than hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide on subdomains deliberately.&lt;/strong&gt; &lt;code&gt;sp=&lt;/code&gt; sets the policy for existing subdomains, which otherwise inherit &lt;code&gt;p&lt;/code&gt;. Receivers that follow RFC 9989 also read &lt;code&gt;np=reject&lt;/code&gt;, which covers subdomains that don't exist at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock down domains that never send mail.&lt;/strong&gt; A parked domain can publish &lt;code&gt;v=spf1 -all&lt;/code&gt; and &lt;code&gt;v=DMARC1; p=reject&lt;/code&gt; on day one. There's nothing to monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After each step, send a test message from every service in bucket two and check that the &lt;code&gt;Authentication-Results&lt;/code&gt; header on the received copy shows &lt;code&gt;dmarc=pass&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reject actually buys you
&lt;/h2&gt;

&lt;p&gt;At &lt;code&gt;p=reject&lt;/code&gt;, receivers that honor DMARC refuse mail that claims to be from your domain and can't prove it. Phishing that copies your exact address stops reaching customers' inboxes, and your own mail stops sharing a reputation with whoever was spoofing it. Look-alike domains are a separate problem, and DMARC doesn't cover them.&lt;/p&gt;

&lt;p&gt;None of this needs special software. A few weeks of reading the reports, fixing the senders you recognize, and reject becomes a short DNS edit instead of a gamble. If you're moving mailboxes as part of the cleanup, Pro Gineous &lt;a href="https://progineous.com/business-email.php?utm_source=devto" rel="noopener noreferrer"&gt;business email&lt;/a&gt; gives each address a mailbox on your own domain, and every check above works the same there as with any other provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-keep-email-out-of-spam/" rel="noopener noreferrer"&gt;Why Is My Email Going to Spam?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/read-dig-output-dns/" rel="noopener noreferrer"&gt;How to Read dig Output When DNS Looks Wrong&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-set-up-dns-records/" rel="noopener noreferrer"&gt;Setting Up DNS Records for Your Website and Mail&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/read-dmarc-reports/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>dns</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why WordPress Missed Your Scheduled Post (and How to Fix WP-Cron)</title>
      <dc:creator>Pro Gineous</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:38:46 +0000</pubDate>
      <link>https://dev.to/progineous/why-wordpress-missed-your-scheduled-post-and-how-to-fix-wp-cron-4e90</link>
      <guid>https://dev.to/progineous/why-wordpress-missed-your-scheduled-post-and-how-to-fix-wp-cron-4e90</guid>
      <description>&lt;p&gt;You scheduled a post for 11:00, closed the laptop, and came back after lunch to find it still sitting in the Posts list with a red "Missed schedule" label where the publish date should be. Nothing crashed. No error email arrived. WordPress just never got around to it.&lt;/p&gt;

&lt;p&gt;The cause is almost always WP-Cron, the scheduler built into WordPress. It makes one big assumption about your traffic, and when that assumption fails, scheduled posts, backups and digest emails all slip together. Here's how it works, how to confirm it's the culprit, and how to replace it with something that runs on time.&lt;/p&gt;

&lt;h2&gt;
  
  
  WP-Cron isn't a clock
&lt;/h2&gt;

&lt;p&gt;When you click Schedule, WordPress doesn't set a timer anywhere. It saves the post with the status &lt;code&gt;future&lt;/code&gt; and adds a one-off job called &lt;code&gt;publish_future_post&lt;/code&gt; to a list stored in the database (the &lt;code&gt;cron&lt;/code&gt; option in &lt;code&gt;wp_options&lt;/code&gt;). Plugin chores like backups and update checks live in the same list.&lt;/p&gt;

&lt;p&gt;Nothing watches that list on its own. Instead, on each page request WordPress checks whether anything on it is due. If something is, it fires a quick HTTP request at its own &lt;code&gt;wp-cron.php&lt;/code&gt; (a loopback request) and carries on without waiting for the reply. That second request is what actually publishes your post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuio5klpuxu293v47wz8f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuio5klpuxu293v47wz8f.png" alt="Flat diagram comparing two ways WordPress publishes a scheduled post: the default visit-triggered WP-Cron path (visitor arrives, WordPress checks the job list, loopback request to wp-cron.php, job runs) with its failure points, and a server cron job running wp cron event run --due-now every five minutes" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the default setup needs three things to line up: a visit that reaches PHP, a loopback request that gets through, and a server that lets the job finish. Take away any one of them and the schedule drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the schedule slips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nobody visited.&lt;/strong&gt; A small site can go hours without one uncached request. The post is due at 11:00, the first real visit comes at 12:40, and that's when it goes live. Posts on a quiet site appearing late by a random amount is the classic pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The visits never reached WordPress.&lt;/strong&gt; Full-page caching (a caching plugin's static files, a server-level cache, or a CDN in front of the site) is great for speed, but a cached page is served without loading WordPress, so nothing checks the job list. A busy site behind a good cache can behave exactly like a site with no visitors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The loopback request fails.&lt;/strong&gt; The server has to be able to reach its own domain over HTTP. A staging password (HTTP basic auth) answers &lt;code&gt;401&lt;/code&gt;. A security plugin or firewall rule may block &lt;code&gt;wp-cron.php&lt;/code&gt;. After a migration, the server can still resolve your domain to the old IP (DNS answers are cached, as covered in &lt;a href="https://blog.progineous.com/how-long-dns-changes-take/" rel="noopener noreferrer"&gt;how long DNS changes take to show up&lt;/a&gt;) and send the request to a machine that no longer hosts the site. WordPress flags this in Tools &amp;gt; Site Health as "Your site could not complete a loopback request."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cron was switched off and never replaced.&lt;/strong&gt; Someone added &lt;code&gt;define( 'DISABLE_WP_CRON', true );&lt;/code&gt; to &lt;code&gt;wp-config.php&lt;/code&gt; to stop cron running on page loads, which is a sensible thing to do, but the server cron job that's meant to take over was never created. Or it existed on the old server and didn't come along in the move, because it lives in the server's crontab, not in WordPress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A heavy job ran out of time.&lt;/strong&gt; Cron jobs run in a normal PHP request, so they're bound by the same time limit. A big backup or import that hits that limit gets killed mid-run, and whatever was queued after it waits for the next spawn.&lt;/p&gt;

&lt;p&gt;Also rule out the time zone under Settings &amp;gt; General. If it's set to UTC while you think in local time, every post goes out "late" by the same few hours. That's a settings problem, not a scheduler one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask WordPress what it thinks is due
&lt;/h2&gt;

&lt;p&gt;If your hosting gives you SSH, WP-CLI answers this in three commands. Run them from the site's folder (often &lt;code&gt;public_html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp post list &lt;span class="nt"&gt;--post_status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;future &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ID,post_title,post_date
wp cron event list
wp cron &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn0nue0uv6saa43o1zsm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn0nue0uv6saa43o1zsm.png" alt="Terminal screenshot of WP-CLI showing a scheduled post with ID 1482 still in future status, wp cron event list with publish_future_post and core jobs overdue as now, and wp cron test failing with cURL error 28 operation timed out" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first command lists posts still waiting to publish, with the time they were due in the site's own time zone. In &lt;code&gt;wp cron event list&lt;/code&gt;, the &lt;code&gt;next_run_gmt&lt;/code&gt; column is in UTC, so on a site set to Cairo time (UTC+3) the &lt;code&gt;08:00:00&lt;/code&gt; job is the 11:00 post. Any row whose &lt;code&gt;next_run_relative&lt;/code&gt; says &lt;code&gt;now&lt;/code&gt; is overdue: WordPress knows the job should run and nothing has run it. One overdue row could be bad luck. A column full of &lt;code&gt;now&lt;/code&gt;, including core jobs like &lt;code&gt;wp_version_check&lt;/code&gt;, means cron hasn't run for hours.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wp cron test&lt;/code&gt; makes the same loopback request WordPress would, but waits up to three seconds for the answer. Here's how to read the result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Success: WP-Cron spawning is working as expected.&lt;/code&gt; The loopback path is fine, so look at traffic and caching instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cURL error 28&lt;/code&gt; (a timeout, as in the screenshot) usually points at a firewall rule, a DNS answer pointing somewhere else, or a server that can't reach itself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HTTP status code: 401&lt;/code&gt; means a password sits in front of the site.&lt;/li&gt;
&lt;li&gt;An error saying the &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; constant is set means cron is switched off on purpose, so go and find the server cron job that should exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No SSH? The free WP Crontrol plugin shows the same event list inside wp-admin, and Site Health reports late scheduled events and failed loopback requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand the schedule to the server
&lt;/h2&gt;

&lt;p&gt;The reliable fix is to stop depending on visits at all. Let the server's own scheduler run the queue every few minutes, and tell WordPress to stop trying on page loads.&lt;/p&gt;

&lt;p&gt;Start by switching off the visit-triggered runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp config &lt;span class="nb"&gt;set &lt;/span&gt;DISABLE_WP_CRON &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--raw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes &lt;code&gt;define( 'DISABLE_WP_CRON', true );&lt;/code&gt; into &lt;code&gt;wp-config.php&lt;/code&gt;. You can also add the line by hand, anywhere above the &lt;code&gt;/* That's all, stop editing! */&lt;/code&gt; comment.&lt;/p&gt;

&lt;p&gt;Next, add a server cron job. Cron runs with a very short &lt;code&gt;PATH&lt;/code&gt;, so find the full path to WP-CLI with &lt;code&gt;which wp&lt;/code&gt; and use it in the line. With WP-CLI available, this is the cleanest version, because it runs due events directly in PHP instead of going through HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /home/exampleco/public_html &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; /usr/local/bin/wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without WP-CLI, request &lt;code&gt;wp-cron.php&lt;/code&gt; over HTTP instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"https://example.com/wp-cron.php?doing_wp_cron"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP version still depends on the server reaching itself, so if &lt;code&gt;wp cron test&lt;/code&gt; failed earlier, fix that first or stick with WP-CLI. Over SSH, add the line with &lt;code&gt;crontab -e&lt;/code&gt;. On cPanel hosting, the Cron Jobs page takes the same command and has a ready-made "Once Per Five Minutes" option. Five minutes is a good default: scheduled posts go out within a few minutes of their time, and the server barely notices.&lt;/p&gt;

&lt;p&gt;Finally, clear the backlog once by hand with &lt;code&gt;wp cron event run --due-now&lt;/code&gt;, then check the stuck post with &lt;code&gt;wp post get 1482 --field=post_status&lt;/code&gt;. It should now say &lt;code&gt;publish&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs36z4x5ph19rl3h11xiq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs36z4x5ph19rl3h11xiq.png" alt="Terminal screenshot of fixing WP-Cron with WP-CLI: wp config set DISABLE_WP_CRON true, a crontab line running wp cron event run --due-now every five minutes, the due events executing including publish_future_post, and post 1482 now showing publish status" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  If you really can't add a cron job
&lt;/h3&gt;

&lt;p&gt;Some plans offer neither cron nor SSH. One fallback is an external cron service that requests &lt;code&gt;https://example.com/wp-cron.php?doing_wp_cron&lt;/code&gt; every few minutes, which works as long as that URL isn't blocked or cached. The other is &lt;code&gt;define( 'ALTERNATE_WP_CRON', true );&lt;/code&gt;, which makes WordPress run cron by redirecting a visitor's own request instead of making a loopback call. It sidesteps a broken loopback but still needs visits, and visitors may see &lt;code&gt;?doing_wp_cron=&lt;/code&gt; in the address bar. Treat both as stopgaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it the next day
&lt;/h2&gt;

&lt;p&gt;The day after the change, check that it actually works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schedule a test post ten minutes out and stay off the site. If it goes live on time with no visits from you, the server cron is doing the work. (If new posts are shared to social media automatically, pause that first, then trash the test post.)&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;wp cron event list&lt;/code&gt; again. Recurring jobs like &lt;code&gt;wp_version_check&lt;/code&gt; should show a future time, not &lt;code&gt;now&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the cron line to your migration notes. The next time the site moves hosts, it's the one setting that won't move with the files and database, and if both old and new servers keep running cron against the same site during a cutover, scheduled emails can go out twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WP-Cron is a reasonable default for a site that gets steady, uncached traffic. For anything that publishes on a schedule or runs nightly backups, a five-minute server cron job is the boring, dependable choice. Pro Gineous &lt;a href="https://progineous.com/shared-hosting.php?utm_source=devto" rel="noopener noreferrer"&gt;shared hosting&lt;/a&gt; plans include cPanel, SSH and WP-CLI, so everything above can be set up from the control panel or the terminal in a few minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Related reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-speed-up-wordpress/" rel="noopener noreferrer"&gt;Speeding Up WordPress Without More Plugins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-move-a-wordpress-site/" rel="noopener noreferrer"&gt;How to Move a WordPress Site to a New Host&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.progineous.com/how-to-fix-a-wordpress-error/" rel="noopener noreferrer"&gt;How to Fix a WordPress Error Without Making It Worse&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://blog.progineous.com/wordpress-missed-schedule-wp-cron/" rel="noopener noreferrer"&gt;Pro Gineous blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>devops</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
