<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>SSL Certificate | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/ssl-certificate/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/ssl-certificate/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sat, 19 Sep 2026 14:03:05 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>SSL Certificate | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/ssl-certificate/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>It Works in My Browser: Debugging TLS Certificate Errors in Chains, SANs and Expiry</title>
		<link>https://john-nessime.com/blog/system-administration/debugging-tls-certificate-errors/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:23 +0000</pubDate>
				<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[ACME]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Certbot]]></category>
		<category><![CDATA[Certificate Chain]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OpenSSL]]></category>
		<category><![CDATA[SSL Certificate]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[TLS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=64</guid>

					<description><![CDATA[<p>Your browser shows a padlock and your partner's API client throws "unable to get local issuer certificate". Both are correct. A practical guide to the four things a TLS client actually checks, and to renewal automation that reaches the running server instead of just the disk.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/debugging-tls-certificate-errors/">It Works in My Browser: Debugging TLS Certificate Errors in Chains, SANs and Expiry</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A partner emails to say your API has been rejecting their integration for two days. Their client throws <code>unable to get local issuer certificate</code>. You open the URL in Chrome, see a padlock, and reply that it works fine on your end. Then someone runs <code>curl</code> from a clean container and it fails there too.</p>



<p class="wp-block-paragraph">Your server is only sending the leaf certificate. The intermediate that links it to a trusted root is missing. Your browser papers over that: it either has the intermediate cached from another site, or it quietly fetches it using the AIA extension in the certificate. <code>curl</code>, Java, Go, Python and every other non-browser client do no such thing. They see a certificate signed by something they do not trust and stop.</p>



<p class="wp-block-paragraph">This is the most common of all <strong>TLS certificate errors</strong> and it is close to invisible from a desktop. The padlock in your browser is not a test. It is a client with a large safety net doing you a favour.</p>



<p class="wp-block-paragraph">This post covers the four things a client actually checks and how each one fails: the chain, the names, the clock, and the trust store. Then the automation half, which has its own quiet failure: a renewal that succeeds and never reaches the running server. Plus the two things about TLS that have genuinely changed recently and will break config you copied from an older guide.</p>



<h2 class="wp-block-heading">Look at what the server is actually sending</h2>



<p class="wp-block-paragraph">Before theorising, get the facts. One command tells you almost everything.</p>



<pre class="wp-block-code"><code># -servername sends SNI. Without it you get whatever the default
# vhost serves, which is a different certificate on most servers
# and the source of a lot of confusing results.
# -showcerts prints every certificate the server sent, in order.
openssl s_client -connect example.com:443 -servername example.com -showcerts &lt;/dev/null</code></pre>



<p class="wp-block-paragraph">Read the top of that output. There is a <code>Certificate chain</code> block listing what arrived, numbered from zero, with <code>s:</code> for subject and <code>i:</code> for issuer. Entry 0 is your certificate. Entry 1 should be the intermediate that issued it, and its subject should exactly match entry 0&#8217;s issuer.</p>



<p class="wp-block-paragraph">If the chain block has only entry 0, that is your bug. Right at the bottom, <code>Verify return code</code> gives the verdict: <code>0 (ok)</code> is what you want, <code>21 (unable to verify the first certificate)</code> means the chain is incomplete, and <code>20 (unable to get local issuer certificate)</code> means the issuer is not in the local trust store.</p>



<p class="wp-block-paragraph">Run it from a machine that is not yours. A clean container is ideal, because your laptop has accumulated intermediates and corporate roots that a customer&#8217;s server has not.</p>



<h2 class="wp-block-heading">Chain problems</h2>



<p class="wp-block-paragraph">Almost all of these come down to one file choice. ACME clients write out several files and two of them look interchangeable:</p>



<ul class="wp-block-list">
<li><strong><code>cert.pem</code></strong> is the leaf alone. Using this is the mistake.</li>
<li><strong><code>chain.pem</code></strong> is the intermediates alone.</li>
<li><strong><code>fullchain.pem</code></strong> is leaf plus intermediates, in the right order. This is the one your server should point at.</li>
</ul>



<pre class="wp-block-code"><code># nginx
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;</code></pre>



<pre class="wp-block-code"><code># Apache. On current versions the chain goes in SSLCertificateFile.
# SSLCertificateChainFile is deprecated; guides that still use it
# are old enough that other advice in them is probably stale too.
SSLCertificateFile      /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile   /etc/letsencrypt/live/example.com/privkey.pem</code></pre>



<p class="wp-block-paragraph">Order matters as well as contents. The leaf must come first, then each intermediate that signs the one above it. A chain assembled in the wrong order will be rejected by strict clients even though every certificate in it is correct. You can check a chain offline before deploying it:</p>



<pre class="wp-block-code"><code># Does this leaf chain to a trusted root using these intermediates?
# Exits non-zero and explains itself if not.
openssl verify -untrusted chain.pem cert.pem</code></pre>



<p class="wp-block-paragraph">Do not include the root certificate in the file you serve. The client either has it or does not, and sending it wastes bytes on every handshake without changing any outcome.</p>



<h2 class="wp-block-heading">Name problems</h2>



<p class="wp-block-paragraph">The second family. A certificate can be perfectly valid and still be the wrong certificate for the name being requested.</p>



<pre class="wp-block-code"><code># The hostname the client asked for must appear in here.
openssl x509 -in cert.pem -noout -text | grep -A1 "Subject Alternative Name"</code></pre>



<p class="wp-block-paragraph">Three things people get wrong here:</p>



<ul class="wp-block-list">
<li><strong>The Common Name does not count.</strong> Modern clients ignore the CN field entirely for hostname matching. If the name is only in the CN and not in the SAN list, it will fail, and the error message will not explain that clearly.</li>
<li><strong>Wildcards are exactly one label deep.</strong> <code>*.example.com</code> covers <code>www.example.com</code> and <code>api.example.com</code>. It does not cover <code>example.com</code> itself, and it does not cover <code>a.b.example.com</code>. The bare domain has to be listed separately, and it usually is not.</li>
<li><strong>SNI decides which certificate you get.</strong> One IP serving many sites picks the certificate based on the name the client sends during the handshake. A client that does not send SNI, or an <code>openssl</code> command without <code>-servername</code>, gets the default vhost&#8217;s certificate and produces a name mismatch that has nothing to do with your actual configuration.</li>
</ul>



<h2 class="wp-block-heading">Time and trust problems</h2>



<pre class="wp-block-code"><code># Local file
openssl x509 -in cert.pem -noout -dates

# Live endpoint, subject and dates in one go
openssl s_client -connect example.com:443 -servername example.com &lt;/dev/null 2&gt;/dev/null 
  | openssl x509 -noout -dates -subject</code></pre>



<p class="wp-block-paragraph">Expired is obvious. <code>notBefore</code> in the future is the interesting one, because it almost never means the certificate is wrong. It means the client&#8217;s clock is wrong. Containers with no NTP, a VM restored from a snapshot, an embedded device with a dead battery: all of them produce &#8220;certificate not yet valid&#8221; against a perfectly good certificate. Check the clock on the failing machine before you touch anything on the server.</p>



<p class="wp-block-paragraph">Trust store problems look similar but come from a different place. The root that signed your chain has to be in the trust store of the machine making the request, and old systems have old trust stores. A device that has not received an OS update in years may simply not know about a certificate authority that appeared since. There is no server-side fix for that beyond choosing a CA with broader legacy coverage, which is one of the few genuine arguments for a paid certificate.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The renewal that succeeded and changed nothing</h2>



<p class="wp-block-paragraph">Here is the second invisible failure, and it catches experienced people. Your renewal timer runs, the ACME client obtains a new certificate, writes it to disk, logs success. The site goes down anyway.</p>



<p class="wp-block-paragraph">Nginx read the certificate into memory when it started. It does not watch the file. Neither does Apache, HAProxy, Postfix, or almost anything else. Until the service is reloaded it keeps serving the certificate it loaded at boot, which is the one that just expired. Everything in your logs says the renewal worked, because it did. The delivery step was never wired up.</p>



<p class="wp-block-paragraph">Certbot handles this with deploy hooks, which run only when a certificate was actually renewed. Put a script in the hooks directory and it applies to every certificate on the box:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh
# Remember: chmod +x, or it silently never runs.
set -euo pipefail

# Validate the config BEFORE reloading. A reload with a broken
# config can take the site down at the exact moment you were
# trying to keep it up.
nginx -t
systemctl reload nginx

# Anything else that holds a copy of the certificate.
# systemctl reload postfix</code></pre>



<p class="wp-block-paragraph">Then prove the whole path works without waiting sixty days to find out:</p>



<pre class="wp-block-code"><code># Exercises the full renewal against the staging environment.
# Installs nothing, counts against no rate limits, and will
# fail loudly if your challenge setup is broken.
certbot renew --dry-run</code></pre>



<p class="wp-block-paragraph">One caveat worth knowing: <code>--dry-run</code> does not run deploy hooks by default, precisely because nothing was deployed. So it validates the issuance path but not the reload. Test that separately by touching the service and confirming the served certificate changes.</p>



<h2 class="wp-block-heading">Expiry automation, and why it is no longer optional</h2>



<p class="wp-block-paragraph">This part has a date attached, so it is worth stating plainly rather than dancing around it.</p>



<p class="wp-block-paragraph">The CA/Browser Forum approved a phased reduction of maximum certificate lifetimes. The cap dropped from 398 days to 200 days on 15 March 2026, falls to 100 days on 15 March 2027, and reaches 47 days on 15 March 2029. Domain validation reuse periods shrink alongside it.</p>



<p class="wp-block-paragraph">The practical consequence: any process that involves a person remembering to renew something once a year is already broken, and by the third phase you are renewing every six weeks. Whatever you were getting away with manually, stop. ACME with a timer is the baseline. Certbot is the default choice, acme.sh is lighter and easier to script around, and Caddy obtains and renews certificates with no configuration at all, which is a genuinely good reason to consider it for a simple reverse proxy.</p>



<h3 class="wp-block-heading">Choosing a challenge type</h3>



<ul class="wp-block-list">
<li><strong>HTTP-01</strong> proves control by serving a file over port 80. Simple, and it breaks whenever port 80 is closed, redirected wholesale to HTTPS in a way that mangles the challenge path, or sitting behind a proxy that does not forward it.</li>
<li><strong>DNS-01</strong> proves control by writing a TXT record. More setup, needs an API token for your DNS provider, and it is the only option for wildcard certificates. It also works for hosts with no inbound internet access at all, which is why I reach for it on anything internal.</li>
</ul>



<p class="wp-block-paragraph">The trade-off with DNS-01 is a credential: your renewal process now holds a token that can edit your DNS zone. Scope it to the narrowest permission your provider offers, and if they support delegating just the <code>_acme-challenge</code> records to a separate zone, do that instead.</p>



<h3 class="wp-block-heading">Monitor expiry externally</h3>



<p class="wp-block-paragraph">Do not rely on the renewal system reporting its own health. If it is broken, it is also probably not telling you. Let&#8217;s Encrypt has discontinued its expiration reminder emails, so if that was quietly acting as your safety net, it is not one any more.</p>



<p class="wp-block-paragraph">Check from outside, against the live endpoint, the way a user would:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

HOST="example.com"
DAYS=21

# -checkend takes a number of seconds and exits non-zero if the
# certificate expires within that window. No parsing dates by hand.
if ! openssl s_client -connect "$HOST:443" -servername "$HOST" &lt;/dev/null 2&gt;/dev/null 
     | openssl x509 -noout -checkend $(( DAYS * 86400 )); then
  echo "TLS certificate for $HOST expires within $DAYS days" &gt;&amp;2
  exit 1
fi</code></pre>



<p class="wp-block-paragraph">If you already run Prometheus, the blackbox exporter gives you <code>probe_ssl_earliest_cert_expiry</code> for free, which is a timestamp you can alert on and graph in Grafana across every endpoint you own. That is the better answer at more than a handful of hosts, because it also catches the certificates nobody remembered existed.</p>



<h2 class="wp-block-heading">Two pieces of config that are now wrong</h2>



<p class="wp-block-paragraph"><strong>OCSP stapling with Let&#8217;s Encrypt.</strong> Let&#8217;s Encrypt removed the OCSP responder URL from newly issued certificates in May 2025 and shut down its responders in August 2025. If your config still has this, it does nothing except print a warning on every reload:</p>



<pre class="wp-block-code"><code># nginx: [warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate
# Comment these out if your certificates come from Let's Encrypt.
# ssl_stapling on;
# ssl_stapling_verify on;</code></pre>



<p class="wp-block-paragraph">Nothing breaks by leaving them, but a warning that appears on every reload is a warning people learn to ignore, and then they ignore the next one too.</p>



<p class="wp-block-paragraph"><strong>Hand-tuned cipher suite lists.</strong> If you pasted a <code>ssl_ciphers</code> line from a guide more than a couple of years old, re-check it against the current Mozilla SSL Configuration Generator. Lists that were considered hardened at the time now sometimes exclude things you want or include things you do not, and the defaults have improved considerably.</p>



<h2 class="wp-block-heading">When there is a CDN in front</h2>



<p class="wp-block-paragraph">Behind Cloudflare or a similar edge, there are two separate TLS connections: browser to edge, and edge to your origin. They use different certificates and fail independently.</p>



<p class="wp-block-paragraph">This creates a specific blind spot. Your origin certificate can be expired, self-signed or serving the wrong name, and visitors will see nothing wrong, because the edge presents its own valid certificate and, in the more permissive encryption modes, does not verify the origin. It works until you switch to strict origin validation, or until the edge is bypassed, and then everything fails at once for reasons that look unrelated to anything you changed.</p>



<p class="wp-block-paragraph">Test the origin directly by name, bypassing DNS. If your origin is a VPS with a provider like InterServer and the public DNS points at the CDN, this is the only way to see what the origin is really serving:</p>



<pre class="wp-block-code"><code># --resolve forces the hostname to a specific IP without touching
# /etc/hosts, so SNI and Host header stay correct.
curl -v --resolve example.com:443:203.0.113.10 https://example.com/</code></pre>



<h2 class="wp-block-heading">Troubleshooting by error message</h2>



<h3 class="wp-block-heading">unable to get local issuer certificate</h3>



<p class="wp-block-paragraph">Missing intermediate on the server, or a trust store on the client that lacks the root. Check the server first with <code>-showcerts</code>; if the chain is complete, the problem is on the client side.</p>



<h3 class="wp-block-heading">certificate has expired, but I just renewed it</h3>



<p class="wp-block-paragraph">The service was never reloaded. Compare the dates on disk with the dates being served. If they differ, that is your answer, and the fix is a deploy hook rather than another manual renewal.</p>



<h3 class="wp-block-heading">Hostname mismatch on a name that is definitely in the certificate</h3>



<p class="wp-block-paragraph">Either SNI is not reaching the right vhost, or the name is in the Common Name rather than the SAN list, or you are hitting a wildcard at the wrong depth. Check the SAN list and re-run <code>s_client</code> with an explicit <code>-servername</code>.</p>



<h3 class="wp-block-heading">Works over IPv4, fails over IPv6 (or the reverse)</h3>



<p class="wp-block-paragraph">Two different machines are answering, and only one of them was updated. Force the family with <code>curl -4</code> and <code>curl -6</code> and compare. Load balancers with one member out of sync produce the same intermittent pattern.</p>



<h3 class="wp-block-heading">Renewal fails with a challenge error</h3>



<p class="wp-block-paragraph">For HTTP-01, confirm that <code>http://example.com/.well-known/acme-challenge/test</code> is reachable from outside without being redirected or rewritten. A blanket redirect to HTTPS is the usual culprit. For DNS-01, check that the TXT record actually appears with <code>dig</code>, and give slow-propagating providers more time.</p>



<h3 class="wp-block-heading">Everything looks right and it still fails</h3>



<p class="wp-block-paragraph">Run an external scan. SSL Labs or a local run of <code>testssl.sh</code> will check things you did not think to, including protocol support and chain issues from the perspective of clients you do not have installed.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Pointing the server at <code>cert.pem</code> instead of <code>fullchain.pem</code>.</li>
<li>Treating a padlock in your own browser as proof the certificate is correctly deployed.</li>
<li>Testing with <code>openssl s_client</code> without <code>-servername</code> and debugging the wrong certificate.</li>
<li>Assuming <code>*.example.com</code> covers the bare domain.</li>
<li>Putting the hostname only in the Common Name.</li>
<li>Renewing without reloading the service.</li>
<li>Forgetting <code>chmod +x</code> on a deploy hook, so it silently never runs.</li>
<li>Relying on the renewal system to tell you when it is broken.</li>
<li>Including the root certificate in the served chain.</li>
<li>Leaving OCSP stapling enabled with certificates that no longer support it.</li>
<li>Ignoring the origin certificate because the CDN hides its state.</li>
<li>Debugging a &#8220;not yet valid&#8221; certificate on the server when the client&#8217;s clock is wrong.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Serve the full chain, leaf first, without the root.</li>
<li>Verify from a clean machine or container, never from the laptop you browse on.</li>
<li>Put every name you serve in the SAN list, including the bare domain alongside any wildcard.</li>
<li>Automate renewal with ACME and a timer, and run <code>certbot renew --dry-run</code> after any change to the setup.</li>
<li>Attach a deploy hook that validates the config before reloading, and make it executable.</li>
<li>Monitor expiry externally against the live endpoint, and alert with weeks of headroom.</li>
<li>Use DNS-01 for wildcards and internal hosts, with a narrowly scoped API token.</li>
<li>Keep NTP running everywhere, including inside containers.</li>
<li>Test the origin directly when there is a CDN in front, not just the public hostname.</li>
<li>Inventory your certificates, because the one that takes you down is the one nobody remembered.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Why does my certificate work in Chrome but fail in curl?</h3>



<p class="wp-block-paragraph">Your server is not sending the intermediate certificate. Browsers work around this by caching intermediates from other sites and by fetching them from the URL in the certificate&#8217;s AIA extension. Command-line tools and application HTTP libraries do neither. Switch the server to <code>fullchain.pem</code> and reload.</p>



<h3 class="wp-block-heading">What is the difference between cert.pem, chain.pem and fullchain.pem?</h3>



<p class="wp-block-paragraph">Leaf only, intermediates only, and both concatenated in the correct order. Web servers want <code>fullchain.pem</code>. The private key stays in <code>privkey.pem</code> and never goes in any of them.</p>



<h3 class="wp-block-heading">Does a wildcard certificate cover the root domain?</h3>



<p class="wp-block-paragraph">No. <code>*.example.com</code> matches exactly one label, so it covers <code>www.example.com</code> but not <code>example.com</code> and not <code>a.b.example.com</code>. Request the bare domain as an additional SAN, which most ACME clients will do if you pass both names.</p>



<h3 class="wp-block-heading">How often should certificates renew now?</h3>



<p class="wp-block-paragraph">Whatever your CA issues, renew at roughly two-thirds of the lifetime so a failed attempt has room to retry before anything expires. Since maximum lifetimes are on a published downward schedule, build for automated renewal at any interval rather than tuning a number you will have to change again.</p>



<h3 class="wp-block-heading">Should I still enable OCSP stapling?</h3>



<p class="wp-block-paragraph">Not with Let&#8217;s Encrypt certificates, which no longer carry a responder URL. If your CA still supports it, stapling remains worthwhile. Check whether your certificate actually contains an OCSP URL before enabling the directives, rather than assuming.</p>



<h3 class="wp-block-heading">Is a paid certificate more secure than a free one?</h3>



<p class="wp-block-paragraph">Cryptographically, no. The encryption is identical. What money buys is broader compatibility with old trust stores, longer validation reuse, warranties, and human support when something goes wrong. For a normal public website, a free automated certificate is the right default.</p>



<h3 class="wp-block-heading">How do I get a certificate for something with no public DNS or open ports?</h3>



<p class="wp-block-paragraph">DNS-01, on a public domain you control, pointed at whatever internal address you like. The CA only needs to see the TXT record; it never needs to reach the host. This is how internal services get publicly trusted certificates without being exposed.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Most TLS certificate errors are not about cryptography. They are about delivery: whether the server sends everything the client needs, whether the name it sends matches the name that was asked for, and whether the certificate sitting in memory is the same one sitting on disk.</p>



<p class="wp-block-paragraph">So stop checking with your browser. Check with <code>openssl s_client</code> from a machine that owes you no favours, and make sure something outside your server is watching the expiry date. As lifetimes keep shrinking, the gap between a renewal process that works and one that merely appears to work gets narrower every year.</p>



<h2 class="wp-block-heading">Certificates causing you trouble?</h2>



<p class="wp-block-paragraph">Certificate problems tend to arrive at bad moments and turn out to be one wrong file path. Things I help with:</p>



<ul class="wp-block-list">
<li>Diagnosing a certificate that works for some clients and not others, and telling you exactly which link in the chain is missing.</li>
<li>Setting up ACME renewal with deploy hooks across nginx, Apache, HAProxy, mail servers and anything else holding a copy.</li>
<li>Migrating to DNS-01 for wildcards and internal hosts, with properly scoped API credentials.</li>
<li>Certificate inventory and expiry monitoring in Prometheus and Grafana, so nothing expires without warning.</li>
<li>Preparing a certificate estate for shorter lifetimes, replacing anything that still depends on a person remembering.</li>
<li>Origin and edge TLS configuration behind a CDN, including strict origin validation.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>openssl s_client -connect yourhost:443 -servername yourhost -showcerts</code> and the error your failing client reports, and I will tell you what is wrong.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/debugging-tls-certificate-errors/">It Works in My Browser: Debugging TLS Certificate Errors in Chains, SANs and Expiry</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>TLS Certificate Errors: Chain Issues, SANs, and Expiry Automation</title>
		<link>https://john-nessime.com/blog/system-administration/tls-certificate-errors/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 15:02:33 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Certbot]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OpenSSL]]></category>
		<category><![CDATA[SSL Certificate]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[TLS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=31</guid>

					<description><![CDATA[<p>Almost every TLS certificate error traces back to one of three causes: a broken chain, a hostname the certificate does not cover, or an expiry nobody was watching. Here is how to tell them apart in under a minute, and how to stop the third one from ever happening again.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/tls-certificate-errors/">TLS Certificate Errors: Chain Issues, SANs, and Expiry Automation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The site loads fine in your browser. Then a mobile app starts throwing handshake failures, a webhook from a payment provider stops arriving, and someone on the team runs <code>curl</code> and gets &#8220;SSL certificate problem: unable to get local issuer certificate&#8221;. Nothing changed on the server. Except something did, and your browser has been quietly covering for it.</p>



<p class="wp-block-paragraph">Nearly every TLS certificate error I have had to chase down comes back to one of three things: the chain your server sends is incomplete, the certificate does not actually cover the hostname being requested, or it expired and no alert fired. This article walks through all three — how to identify which one you are looking at, the exact commands to prove it, and how to set up renewal automation so the third category stops being a category at all.</p>



<h2 class="wp-block-heading">Why TLS certificate errors are so confusing to debug</h2>



<p class="wp-block-paragraph">Two things conspire to make this harder than it should be.</p>



<p class="wp-block-paragraph">The first is that browsers are forgiving in ways other clients are not. When a server sends an incomplete certificate chain, most modern browsers will quietly fetch the missing intermediate using the Authority Information Access extension embedded in the certificate. They fix your misconfiguration on the fly and show you a padlock. Meanwhile <code>curl</code>, Python&#8217;s <code>requests</code>, Java clients, Go services, and older Android devices do no such thing. They fail. This is why &#8220;it works in Chrome&#8221; is worth almost nothing as a diagnostic signal.</p>



<p class="wp-block-paragraph">The second is that error messages describe the symptom rather than the cause. &#8220;Unable to get local issuer certificate&#8221; sounds like a client problem. It usually is not — it usually means your server is not sending the full chain. Learning to translate the message into the actual failure is most of the work.</p>



<h2 class="wp-block-heading">One command that tells you almost everything</h2>



<p class="wp-block-paragraph">Before touching config files, look at what the server is actually sending on the wire:</p>



<pre class="wp-block-code"><code>openssl s_client -connect example.com:443 -servername example.com -showcerts &lt;/dev/null</code></pre>



<p class="wp-block-paragraph">The <code>-servername</code> flag matters more than people expect. It sets SNI, and without it you may be handed the server&#8217;s default certificate rather than the one for the domain you care about. Plenty of confusing &#8220;the certificate is for the wrong domain&#8221; reports come from testing without SNI.</p>



<p class="wp-block-paragraph">Three parts of the output are worth reading carefully:</p>



<ul class="wp-block-list">
<li><strong>The &#8220;Certificate chain&#8221; block</strong> near the top, listing each certificate the server sent with its subject and issuer.</li>

<li><strong>The &#8220;Verify return code&#8221;</strong> at the bottom. <code>0 (ok)</code> is what you want. <code>20</code> and <code>21</code> both point at chain problems.</li>

<li><strong>The subject line of the first certificate</strong>, which tells you whether you even got the certificate you expected.</li>
</ul>



<p class="wp-block-paragraph">If you want the certificate details without the chain noise, pipe it into <code>x509</code>:</p>



<pre class="wp-block-code"><code>echo | openssl s_client -connect example.com:443 -servername example.com 2&gt;/dev/null 
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName</code></pre>



<p class="wp-block-paragraph">That single line answers &#8220;who issued it&#8221;, &#8220;when does it expire&#8221;, and &#8220;what hostnames does it cover&#8221; in one shot. It is the first thing I run.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Family one: certificate chain issues</h2>



<p class="wp-block-paragraph">A publicly trusted certificate is almost never signed directly by a root. There is at least one intermediate CA in between. The client already trusts the root — it ships in the operating system or browser trust store — but it has no idea about the intermediate unless you hand it over. Your server&#8217;s job is to send the leaf certificate plus every intermediate needed to walk up to a trusted root.</p>



<h3 class="wp-block-heading">The classic mistake: serving the leaf without the intermediates</h3>



<p class="wp-block-paragraph">If you use Let&#8217;s Encrypt, certbot writes several files into <code>/etc/letsencrypt/live/example.com/</code>. Two of them look interchangeable and are not:</p>



<ul class="wp-block-list">
<li><code>cert.pem</code> — the leaf certificate on its own.</li>

<li><code>fullchain.pem</code> — the leaf plus the intermediates.</li>

<li><code>chain.pem</code> — the intermediates on their own.</li>

<li><code>privkey.pem</code> — the private key.</li>
</ul>



<p class="wp-block-paragraph">Nginx wants <code>fullchain.pem</code>. Point it at <code>cert.pem</code> and browsers will still work, so the mistake survives testing and surfaces weeks later when an API client fails.</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}</code></pre>



<p class="wp-block-paragraph">On Apache 2.4.8 and later, <code>SSLCertificateFile</code> can hold the leaf and the intermediates concatenated together, and <code>SSLCertificateChainFile</code> is deprecated. If you inherited a config that still uses the old directive, it will work, but it is worth cleaning up:</p>



<pre class="wp-block-code"><code>SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem</code></pre>



<h3 class="wp-block-heading">Wrong order, or a root that should not be there</h3>



<p class="wp-block-paragraph">Order is part of the specification, not a suggestion. The leaf comes first, then each issuer in sequence walking upward. Some clients tolerate a shuffled bundle; several do not, and the ones that do not tend to be the ones you find out about from an angry customer.</p>



<p class="wp-block-paragraph">Including the root certificate at the end is a different case: it is harmless from a trust perspective, since the client ignores it and uses its own copy, but it adds bytes to every single handshake for no benefit. Leave it out.</p>



<h3 class="wp-block-heading">Verifying a chain offline</h3>



<p class="wp-block-paragraph">You can check a bundle before deploying it, which is far better than discovering the problem in production:</p>



<pre class="wp-block-code"><code># Does the leaf verify against the intermediates you plan to serve?
openssl verify -untrusted chain.pem cert.pem

# Print the subject and issuer of every certificate in a bundle, in order
openssl crl2pkcs7 -nocrl -certfile fullchain.pem 
  | openssl pkcs7 -print_certs -noout</code></pre>



<p class="wp-block-paragraph">That second command is the one I reach for most. Read the output top to bottom: the issuer of each certificate should be the subject of the next one down. The moment that pattern breaks, you have found your gap.</p>



<h3 class="wp-block-heading">The key and the certificate do not match</h3>



<p class="wp-block-paragraph">Less common, but it produces a spectacularly unhelpful error. After a renewal where files were copied around by hand, confirm the pair still belongs together — the two hashes must be identical:</p>



<pre class="wp-block-code"><code>openssl x509 -noout -modulus -in cert.pem | openssl md5
openssl rsa  -noout -modulus -in privkey.pem | openssl md5</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Family two: SAN mismatches</h2>



<p class="wp-block-paragraph">The Common Name field is dead. It has been deprecated for hostname matching for years, and Chrome stopped honouring it entirely back in 2017. What matters now is the Subject Alternative Name extension. If the hostname a client requested is not in the SAN list, the connection fails, regardless of what the CN says.</p>



<p class="wp-block-paragraph">Check what a certificate actually covers:</p>



<pre class="wp-block-code"><code># From a file
openssl x509 -noout -ext subjectAltName -in cert.pem

# From a live server
echo | openssl s_client -connect example.com:443 -servername example.com 2&gt;/dev/null 
  | openssl x509 -noout -ext subjectAltName</code></pre>



<h3 class="wp-block-heading">The four SAN mistakes that keep recurring</h3>



<ul class="wp-block-list">
<li><strong>Apex without www, or www without apex.</strong> These are two distinct names. A certificate for <code>example.com</code> does not cover <code>www.example.com</code>. Both need to be in the SAN list, and both need to be passed to certbot with separate <code>-d</code> flags.</li>

<li><strong>Assuming a wildcard covers everything.</strong> It does not. <code>*.example.com</code> matches exactly one label. It covers <code>api.example.com</code>. It does not cover <code>example.com</code> itself, and it does not cover <code>staging.api.example.com</code>. Multi-level subdomains need their own wildcard or their own entry.</li>

<li><strong>A new subdomain that never made it into the renewal.</strong> You add <code>metrics.example.com</code> to nginx, point DNS at it, and forget that the certificate was issued for a fixed list of names. Adding a name means reissuing, not just reloading.</li>

<li><strong>Connecting by IP address.</strong> Almost no public certificate includes an IP SAN, so hitting the server by IP will always fail hostname verification. That is expected behaviour, not a broken certificate.</li>
</ul>



<p class="wp-block-paragraph">One more that catches people out: if you use Cloudflare in proxied mode, the certificate a visitor sees is Cloudflare&#8217;s edge certificate, not yours. Debugging the origin means bypassing the proxy and testing the origin IP directly with the <code>Host</code> header set correctly, or temporarily turning the orange cloud grey.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Family three: expiry, and why automation is no longer optional</h2>



<p class="wp-block-paragraph">Expiry used to be a calendar problem. You bought a certificate for a year, set a reminder, and dealt with it once. That era is closing.</p>



<p class="wp-block-paragraph">In April 2025 the CA/Browser Forum passed ballot SC-081v3, which phases the maximum validity period for publicly trusted TLS certificates down from 398 days to 47 days. The schedule runs in three steps: 200 days from 15 March 2026, 100 days from 15 March 2027, and 47 days from 15 March 2029. The first phase is already in force. Domain validation reuse windows shrink alongside it.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">The 47-day target was chosen deliberately: short enough that manual renewal stops being viable at any meaningful scale. Automation is the point of the change, not a side effect of it.</p>
</blockquote>



<p class="wp-block-paragraph">If you run a handful of domains, this is a nuisance. If you run fifty, manual renewal is already finished as a strategy.</p>



<h3 class="wp-block-heading">Getting ACME renewal right</h3>



<p class="wp-block-paragraph">Certbot installs a systemd timer or cron entry that attempts renewal twice a day and does nothing unless the certificate is close to expiry. Confirm it is actually there:</p>



<pre class="wp-block-code"><code>systemctl list-timers | grep certbot
certbot renew --dry-run</code></pre>



<p class="wp-block-paragraph">The dry run exercises the whole path against the staging environment without touching rate limits. Run it after any change to your web server config, not just after installing certbot. A renewal that worked last quarter can break because someone added a redirect that intercepts the ACME challenge path.</p>



<h3 class="wp-block-heading">The trap: renewal succeeds, the site still serves the old certificate</h3>



<p class="wp-block-paragraph">This is the failure I see most often, and it is genuinely maddening because every log says success. Certbot writes the new certificate to disk. Nginx has the old one loaded in memory and keeps serving it until it is told otherwise. The certificate on disk is valid, the certificate on the wire is expired, and everything looks fine from the server&#8217;s point of view.</p>



<p class="wp-block-paragraph">The fix is a deploy hook, which runs only when a certificate was actually renewed:</p>



<pre class="wp-block-code"><code>certbot renew --deploy-hook "systemctl reload nginx"</code></pre>



<p class="wp-block-paragraph">Better still, make it permanent by adding it to the renewal configuration at <code>/etc/letsencrypt/renewal/example.com.conf</code>, under the <code>[renewalparams]</code> section, so it survives however the renewal happens to be triggered:</p>



<pre class="wp-block-code"><code>renew_hook = systemctl reload nginx</code></pre>



<p class="wp-block-paragraph">Use <code>reload</code> rather than <code>restart</code>. A reload picks up the new certificate without dropping connections.</p>



<p class="wp-block-paragraph">Remember that anything else terminating TLS needs the same treatment. A mail server, a database proxy, a Docker container with the certificate baked into its image — each one needs its own reload step, and containers in particular will happily run for months with an expired certificate mounted from the host.</p>



<h3 class="wp-block-heading">Wildcards need DNS-01</h3>



<p class="wp-block-paragraph">Wildcard certificates cannot be issued through HTTP-01 validation. They require DNS-01, which means certbot needs to create a TXT record on your domain, which means API credentials for your DNS provider. Use a provider plugin rather than the manual mode — manual DNS-01 cannot be automated, and an unautomated wildcard on a 47-day cycle is an outage with a scheduled start time.</p>



<p class="wp-block-paragraph">If issuance fails outright with a policy error, check your CAA records before assuming the ACME client is broken. A CAA record that names a different CA will block issuance entirely:</p>



<pre class="wp-block-code"><code>dig CAA example.com +short</code></pre>



<h3 class="wp-block-heading">Monitor from outside, not from the server</h3>



<p class="wp-block-paragraph">Checking the certificate file on disk tells you what should be served. Only an external check tells you what is being served. Given the reload trap above, that difference is the entire point.</p>



<p class="wp-block-paragraph">A minimal check that works anywhere:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
# Usage: ./cert-days.sh example.com
# Exits non-zero if fewer than 21 days remain.
set -euo pipefail

DOMAIN="$1"
THRESHOLD=21

END=$(echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2&gt;/dev/null 
      | openssl x509 -noout -enddate | cut -d= -f2)

DAYS=$(( ( $(date -d "${END}" +%s) - $(date +%s) ) / 86400 ))
echo "${DOMAIN}: ${DAYS} days remaining (expires ${END})"

[ "${DAYS}" -lt "${THRESHOLD}" ] &amp;&amp; exit 1
exit 0</code></pre>



<p class="wp-block-paragraph">If you already run Prometheus, the blackbox exporter gives you this for free through the <code>probe_ssl_earliest_cert_expiry</code> metric. An alert rule looks like this:</p>



<pre class="wp-block-code"><code>groups:
  - name: tls
    rules:
      - alert: TLSCertificateExpiringSoon
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 &lt; 21
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "TLS certificate expiring in under 21 days"</code></pre>



<p class="wp-block-paragraph">Set the threshold well above your renewal window. With 200-day certificates renewing at 30 days remaining, a 21-day alert gives you nine days of warning that something in the pipeline broke. Once 47-day certificates arrive, those numbers compress hard, and an alert that fires too late is the same as no alert.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A workflow for diagnosing TLS certificate errors</h2>



<p class="wp-block-paragraph">When something breaks, work through it in this order. It takes about a minute and rules out whole families of causes as you go.</p>



<ol class="wp-block-list">
<li><strong>Reproduce outside the browser.</strong> Use <code>curl -v</code> or <code>openssl s_client</code>. If the browser works and curl does not, you are almost certainly looking at a chain problem.</li>

<li><strong>Read the verify return code.</strong> Code 20 or 21 means a chain issue. A hostname mismatch message means a SAN issue. A date error means expiry — or a wrong system clock on the client.</li>

<li><strong>Print the dates.</strong> Rules expiry in or out in one command, and tells you whether the certificate on the wire matches the one on disk.</li>

<li><strong>Print the SANs.</strong> Confirm the exact hostname being requested appears in the list. Watch for apex versus www.</li>

<li><strong>Walk the chain.</strong> Each certificate&#8217;s issuer should be the next one&#8217;s subject. Find where the sequence breaks.</li>

<li><strong>Compare disk against wire.</strong> If the file is newer than what is being served, you have a reload problem, not a certificate problem.</li>

<li><strong>Check the client&#8217;s trust store.</strong> If everything above is clean and one specific client still fails, the problem has moved to their end — an old Android device, a Java service with its own <code>cacerts</code>, or a container image built on a base with a stale CA bundle.</li>
</ol>



<h2 class="wp-block-heading">Common mistakes worth avoiding</h2>



<ul class="wp-block-list">
<li>Testing only in a browser, and mistaking its silent chain repair for a working configuration.</li>

<li>Pointing the web server at <code>cert.pem</code> instead of <code>fullchain.pem</code>.</li>

<li>Renewing without reloading the service that holds the certificate in memory.</li>

<li>Running <code>certbot renew</code> on a schedule but never running <code>--dry-run</code> after config changes.</li>

<li>Copying key and certificate files between servers by hand and losing track of which pair belongs together.</li>

<li>Monitoring the file on disk rather than the certificate actually being served.</li>

<li>Forgetting that mail servers, load balancers, and containers terminate TLS too, and need their own renewal path.</li>

<li>Adding a subdomain to the web server config and assuming the existing certificate covers it.</li>
</ul>



<h2 class="wp-block-heading">Best practices that hold up over time</h2>



<ul class="wp-block-list">
<li>Automate issuance and renewal from day one, even for a single domain. The habit matters more than the scale.</li>

<li>Always use a deploy hook, and always reload rather than restart.</li>

<li>Alert on days remaining, measured externally, with a threshold at least twice your renewal window.</li>

<li>Keep an inventory of every hostname you hold a certificate for. You cannot monitor what you have forgotten about.</li>

<li>Prefer DNS-01 with a provider plugin if you need wildcards, and never rely on manual DNS validation.</li>

<li>Set CAA records deliberately so you know which CAs are permitted to issue for your domain.</li>

<li>Test with a non-browser client before calling a certificate deployment finished.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Why does my site work in Chrome but fail in curl?</h3>



<p class="wp-block-paragraph">Almost always an incomplete chain. Browsers fetch missing intermediate certificates automatically using the Authority Information Access extension; curl does not. Point your web server at the full chain file rather than the leaf certificate and both will work.</p>



<h3 class="wp-block-heading">What does &#8220;unable to get local issuer certificate&#8221; actually mean?</h3>



<p class="wp-block-paragraph">The client could not build a path from your certificate to a root it trusts. Nine times out of ten the server is not sending its intermediates. Occasionally the client&#8217;s own CA bundle is out of date, which is common in older container base images.</p>



<h3 class="wp-block-heading">Does a wildcard certificate cover the root domain?</h3>



<p class="wp-block-paragraph">No. <code>*.example.com</code> matches a single label, so it covers <code>www.example.com</code> but not <code>example.com</code> and not <code>a.b.example.com</code>. Include the apex explicitly as an additional SAN entry when you request the certificate.</p>



<h3 class="wp-block-heading">My certificate renewed but the site still shows it as expired. Why?</h3>



<p class="wp-block-paragraph">The service holding the certificate has not reloaded. The new file is on disk; the old one is still in memory. Add a deploy hook that reloads the service on renewal, then verify from outside with <code>openssl s_client</code> rather than trusting the file timestamp.</p>



<h3 class="wp-block-heading">How short will certificate lifetimes actually get?</h3>



<p class="wp-block-paragraph">Under the CA/Browser Forum schedule, 200 days applies from March 2026, 100 days from March 2027, and 47 days from March 2029. Private and internal CAs are not bound by these rules, but public certificates are.</p>



<h3 class="wp-block-heading">Is the Common Name field still used at all?</h3>



<p class="wp-block-paragraph">Not for hostname validation. Modern clients read only the Subject Alternative Name extension. A certificate whose CN matches but whose SAN list does not include the hostname will be rejected.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">TLS certificate errors feel opaque mostly because the error text points at the wrong layer. Once you sort them into chain, SAN, or expiry, each one has a short diagnostic path and an obvious fix. Get comfortable with <code>openssl s_client</code> and <code>openssl x509</code> and you will resolve most incidents faster than it takes to find the right dashboard.</p>



<p class="wp-block-paragraph">The expiry category is the one worth engineering away permanently. With maximum lifetimes dropping toward 47 days, any process that depends on a person remembering something will fail eventually. Automate the renewal, add the deploy hook, and alert on what is actually being served rather than what is sitting on disk. Do that once and TLS certificate errors go back to being a rare curiosity instead of a recurring outage.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help with TLS, certificates, or infrastructure automation?</h2>



<p class="wp-block-paragraph">I work with teams on exactly this kind of problem — broken certificate chains, ACME automation across multiple servers, expiry monitoring wired into Prometheus and Grafana, and the wider DevOps plumbing that keeps it all running quietly in the background.</p>



<p class="wp-block-paragraph">If you would rather hand this off than debug it at two in the morning, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/tls-certificate-errors/">TLS Certificate Errors: Chain Issues, SANs, and Expiry Automation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>WordPress Migration Without Downtime: A Practical Guide to Moving Your Website Safely</title>
		<link>https://john-nessime.com/blog/wordpress/wordpress-migration-without-downtime/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 16 Jul 2026 20:32:36 +0000</pubDate>
				<category><![CDATA[Hosting & Infrastructure]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Website Management]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[DNS Migration]]></category>
		<category><![CDATA[SSL Certificate]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[Website Downtime]]></category>
		<category><![CDATA[Website Migration]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress Backup]]></category>
		<category><![CDATA[WordPress Database]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=23</guid>

					<description><![CDATA[<p>Moving a WordPress website involves much more than copying files. This practical migration guide explains how to transfer WordPress safely, minimize downtime, protect SEO, preserve email services, and avoid lost data.</p>
<p>The post <a href="https://john-nessime.com/blog/wordpress/wordpress-migration-without-downtime/">WordPress Migration Without Downtime: A Practical Guide to Moving Your Website Safely</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[


<p class="wp-block-paragraph">Moving a WordPress website sounds simple until the site contains years of content, customer data, business emails, analytics integrations, payment systems, and search-engine rankings that cannot afford to disappear.</p>




<p class="wp-block-paragraph">A successful WordPress migration is not just a matter of copying files from one server to another. It is a controlled infrastructure change involving the website files, database, domain records, SSL certificate, email configuration, caching layers, scheduled tasks, security settings, and third-party integrations.</p>




<p class="wp-block-paragraph">When the process is planned correctly, visitors may never notice that the website has moved. When it is rushed, the result can include broken pages, missing images, database errors, lost form submissions, email disruption, redirect loops, certificate warnings, or several hours of downtime.</p>




<p class="wp-block-paragraph">This guide explains how to migrate a WordPress website safely, how to reduce downtime, what can go wrong, and what should be verified before the old hosting environment is finally switched off.</p>




<h2 class="wp-block-heading">What Is a WordPress Migration?</h2>




<p class="wp-block-paragraph">A WordPress migration is the process of moving a WordPress website from one environment to another. The destination might be a new hosting provider, a cloud server, a virtual private server, a staging environment, a new domain, or a different server configuration.</p>




<p class="wp-block-paragraph">The website may look like a single application from the visitor’s perspective, but several separate components normally need to be transferred:</p>




<ul class="wp-block-list">
<li>WordPress core files</li>
<li>Theme files</li>
<li>Plugin files</li>
<li>Uploaded media</li>
<li>The MySQL or MariaDB database</li>
<li>Configuration files</li>
<li>Custom code and server rules</li>
<li>DNS records</li>
<li>SSL certificates</li>
<li>Email-related DNS records</li>
<li>Cron jobs and scheduled tasks</li>
<li>CDN and caching configuration</li>
<li>Analytics and tracking scripts</li>
<li>External API integrations</li>
</ul>




<p class="wp-block-paragraph">A complete migration must preserve both the visible website and the underlying functionality. Copying the homepage successfully is not enough if contact forms stop sending, checkout sessions fail, redirects break, or administrators can no longer log in.</p>




<h2 class="wp-block-heading">Common Reasons for Migrating a WordPress Website</h2>




<p class="wp-block-paragraph">Website owners migrate WordPress installations for many different reasons. In most cases, the decision is connected to performance, reliability, cost, control, or security.</p>




<h3 class="wp-block-heading">Slow Hosting Performance</h3>




<p class="wp-block-paragraph">A website may outgrow its original shared hosting plan. Pages become slow, the WordPress dashboard takes longer to load, and traffic spikes begin causing resource-limit errors. Moving to a better-managed host, VPS, or cloud server can provide more CPU, memory, storage performance, and configuration flexibility.</p>




<h3 class="wp-block-heading">Frequent Downtime</h3>




<p class="wp-block-paragraph">Unreliable infrastructure can damage customer trust and search visibility. If the current host experiences repeated outages or provides weak technical support, migration may be the most practical long-term solution.</p>




<h3 class="wp-block-heading">Security Concerns</h3>




<p class="wp-block-paragraph">Outdated server software, poor account isolation, weak backup systems, or limited firewall controls can expose a WordPress website to unnecessary risk. A migration can be used as an opportunity to rebuild the site on a cleaner and more secure environment.</p>




<h3 class="wp-block-heading">Hosting Cost Optimization</h3>




<p class="wp-block-paragraph">Some hosting plans become expensive as traffic grows, especially when backups, staging, CDN access, email, malware scanning, or additional storage are charged separately. Migrating to a more suitable platform can reduce costs without sacrificing performance.</p>




<h3 class="wp-block-heading">Business Growth</h3>




<p class="wp-block-paragraph">A small brochure website may eventually become a membership platform, online store, booking portal, or content-heavy publication. The original environment may no longer support the website’s operational requirements.</p>




<h3 class="wp-block-heading">Domain or Branding Changes</h3>




<p class="wp-block-paragraph">Some migrations involve changing the domain name as well as the server. This requires additional planning because internal URLs, redirects, analytics properties, search-engine records, cookies, and third-party callbacks may all depend on the original domain.</p>




<h2 class="wp-block-heading">The Difference Between a Simple Migration and a Production Migration</h2>




<p class="wp-block-paragraph">A small personal blog with few updates can often be moved using a migration plugin. A production website requires more care.</p>




<p class="wp-block-paragraph">Production websites may receive new orders, registrations, comments, bookings, support requests, or form submissions while the migration is taking place. If the database is copied too early and DNS is updated hours later, any data created during that gap may remain only on the old server.</p>




<p class="wp-block-paragraph">That is why a production migration should be treated as a controlled cutover rather than a one-click file transfer. The objective is to minimize the period during which the old and new environments contain different information.</p>




<h2 class="wp-block-heading">What Can Go Wrong During a WordPress Migration?</h2>




<p class="wp-block-paragraph">Most migration problems fall into a predictable set of categories. Knowing them in advance makes the process easier to plan and troubleshoot.</p>




<h3 class="wp-block-heading">Database Connection Errors</h3>




<p class="wp-block-paragraph">The WordPress configuration file may contain the wrong database name, username, password, or database host. The user may also lack the necessary permissions on the destination database.</p>




<h3 class="wp-block-heading">Broken Internal URLs</h3>




<p class="wp-block-paragraph">WordPress stores URLs in the database. If the domain, protocol, folder path, or staging address changes, old references can remain inside posts, menus, widgets, page-builder content, serialized options, and plugin settings.</p>




<h3 class="wp-block-heading">Missing Images or Media Files</h3>




<p class="wp-block-paragraph">The uploads directory may be incomplete, excluded from the transfer, or copied with incorrect ownership and permissions. A site may appear functional while older images silently return 404 errors.</p>




<h3 class="wp-block-heading">PHP Compatibility Problems</h3>




<p class="wp-block-paragraph">The destination server may use a newer PHP version than the original host. An outdated theme or plugin can then generate warnings, fatal errors, blank pages, or broken administrative screens.</p>




<h3 class="wp-block-heading">Redirect Loops</h3>




<p class="wp-block-paragraph">Conflicting HTTPS, proxy, CDN, and WordPress redirect rules can cause the browser to repeatedly redirect between two versions of the same URL.</p>




<h3 class="wp-block-heading">Email Delivery Failure</h3>




<p class="wp-block-paragraph">A migration may affect outgoing form emails, SMTP authentication, domain verification, or mailbox routing. DNS changes can also unintentionally overwrite MX, SPF, DKIM, or DMARC records.</p>




<h3 class="wp-block-heading">SSL Certificate Warnings</h3>




<p class="wp-block-paragraph">The destination server may not yet have a valid certificate, or the certificate may not cover every hostname used by the website. Mixed-content warnings can also appear when pages load images, scripts, or stylesheets through HTTP.</p>




<h3 class="wp-block-heading">Lost Transactions or Form Submissions</h3>




<p class="wp-block-paragraph">This is one of the most serious risks. If visitors continue using the old website after the final database backup, their activity may not exist on the new server.</p>




<h2 class="wp-block-heading">Phase One: Audit the Existing Website</h2>




<p class="wp-block-paragraph">Before copying anything, document the current environment. A migration is much safer when the source system is fully understood.</p>




<p class="wp-block-paragraph">The audit should include:</p>




<ul class="wp-block-list">
<li>Current WordPress version</li>
<li>PHP version and PHP extensions</li>
<li>Database engine and version</li>
<li>Active theme and child theme</li>
<li>Active and inactive plugins</li>
<li>Disk usage</li>
<li>Database size</li>
<li>Upload directory size</li>
<li>Web server type</li>
<li>Caching configuration</li>
<li>Scheduled cron tasks</li>
<li>CDN or reverse proxy usage</li>
<li>External SMTP provider</li>
<li>Payment and booking integrations</li>
<li>DNS provider</li>
<li>SSL configuration</li>
<li>Backup availability</li>
</ul>




<p class="wp-block-paragraph">This audit often reveals problems that already existed before the migration. For example, a plugin may be abandoned, the database may contain excessive transient records, or the site may still rely on an unsupported PHP version.</p>




<p class="wp-block-paragraph">A migration should not automatically copy every old problem into the new environment. It is often the right time to clean up unused plugins, remove abandoned themes, review administrator accounts, and confirm that all essential components remain supported.</p>




<h2 class="wp-block-heading">Phase Two: Prepare the Destination Server</h2>




<p class="wp-block-paragraph">The destination environment should be ready before the website is transferred. This reduces last-minute troubleshooting during the cutover.</p>




<p class="wp-block-paragraph">The server preparation usually includes:</p>




<ul class="wp-block-list">
<li>Creating the hosting account or virtual host</li>
<li>Installing the correct PHP version</li>
<li>Enabling required PHP extensions</li>
<li>Creating the database and database user</li>
<li>Configuring file permissions</li>
<li>Setting PHP memory and upload limits</li>
<li>Configuring the web server</li>
<li>Preparing SSL issuance</li>
<li>Configuring backups</li>
<li>Installing monitoring or logging tools</li>
<li>Preparing caching and performance settings</li>
</ul>




<p class="wp-block-paragraph">The destination should ideally match the source environment closely during the first transfer. Major software upgrades can be performed later, after the website is stable. Combining a hosting migration, PHP upgrade, plugin cleanup, theme redesign, and domain change into one maintenance window increases the number of possible failure points.</p>




<h2 class="wp-block-heading">Phase Three: Create Verified Backups</h2>




<p class="wp-block-paragraph">A migration should never begin without a complete backup. More importantly, the backup should be verified rather than merely assumed to be usable.</p>




<p class="wp-block-paragraph">At minimum, create separate copies of:</p>




<ul class="wp-block-list">
<li>The entire WordPress file system</li>
<li>The website database</li>
<li>The WordPress configuration file</li>
<li>Web server rules</li>
<li>DNS records</li>
<li>SSL-related information</li>
<li>Custom cron jobs</li>
<li>Email routing records</li>
</ul>




<p class="wp-block-paragraph">A backup is only valuable if it can be restored. Confirm that the archive opens correctly, the database export is not empty or truncated, and the files include the full uploads directory.</p>




<p class="wp-block-paragraph">For business-critical websites, keeping more than one backup copy is sensible. One copy may remain on the source server, while another is stored externally.</p>




<h2 class="wp-block-heading">Phase Four: Copy the WordPress Files</h2>




<p class="wp-block-paragraph">The files can be transferred using several methods, including a migration plugin, hosting control panel, SFTP, SCP, rsync, or a compressed archive.</p>




<p class="wp-block-paragraph">For small websites, a plugin may be sufficient. Larger websites often benefit from server-level transfer tools because they handle large file sets more efficiently and provide better visibility into transfer errors.</p>




<p class="wp-block-paragraph">During the transfer, confirm that hidden files are included. Files such as <code>.htaccess</code> may contain essential rewrite, security, redirect, or caching rules.</p>




<p class="wp-block-paragraph">After copying, verify ownership and permissions. Incorrect permissions can prevent uploads, plugin updates, cache creation, or WordPress core operations.</p>




<h2 class="wp-block-heading">Phase Five: Export and Import the Database</h2>




<p class="wp-block-paragraph">The database contains the website’s posts, pages, users, settings, menus, plugin data, WooCommerce orders, form entries, and most application-level configuration.</p>




<p class="wp-block-paragraph">It can be exported using tools such as phpMyAdmin, WP-CLI, a migration plugin, or the database command line. Large databases are generally more reliable to move through command-line tools because browser-based imports may fail due to upload or execution limits.</p>




<p class="wp-block-paragraph">Once imported, update the destination <code>wp-config.php</code> file with the correct database details:</p>




<pre class="wp-block-code"><code>define('DB_NAME', 'destination_database');
define('DB_USER', 'destination_user');
define('DB_PASSWORD', 'strong_password');
define('DB_HOST', 'localhost');</code></pre>





<p class="wp-block-paragraph">The exact database host depends on the hosting environment. Some providers use a remote database hostname instead of <code>localhost</code>.</p>




<h2 class="wp-block-heading">Phase Six: Handle URL Changes Correctly</h2>




<p class="wp-block-paragraph">If the website keeps the same domain, fewer URL changes may be required. However, staging URLs, temporary hostnames, HTTP-to-HTTPS changes, subdirectory changes, or domain changes require a controlled search-and-replace operation.</p>




<p class="wp-block-paragraph">A plain text replacement inside a database export can damage serialized WordPress data. WordPress plugins and page builders often store structured values where string lengths matter.</p>




<p class="wp-block-paragraph">Use a WordPress-aware tool such as WP-CLI:</p>




<pre class="wp-block-code"><code>wp search-replace 'https://old-domain.example' 'https://new-domain.example' --all-tables --precise --dry-run</code></pre>





<p class="wp-block-paragraph">The dry run displays the expected replacements without modifying the database. Once reviewed, the operation can be repeated without the <code>--dry-run</code> flag.</p>




<p class="wp-block-paragraph">After the replacement, check:</p>




<ul class="wp-block-list">
<li>Page and post links</li>
<li>Navigation menus</li>
<li>Image URLs</li>
<li>Page-builder templates</li>
<li>Widgets</li>
<li>Logo and favicon paths</li>
<li>Canonical tags</li>
<li>Open Graph images</li>
<li>Plugin callback URLs</li>
<li>Webhook endpoints</li>
</ul>




<h2 class="wp-block-heading">Phase Seven: Test the Website Before Changing DNS</h2>




<p class="wp-block-paragraph">The destination website should be tested before public traffic is sent to it. This is one of the most important steps in a low-downtime migration.</p>




<p class="wp-block-paragraph">A local hosts-file override can map the production domain to the new server only on your own computer. This allows the website to be tested under its real domain while the rest of the internet continues reaching the old server.</p>




<p class="wp-block-paragraph">Testing should cover more than the homepage.</p>




<h3 class="wp-block-heading">Front-End Testing</h3>




<ul class="wp-block-list">
<li>Homepage</li>
<li>Posts and pages</li>
<li>Category and archive pages</li>
<li>Search functionality</li>
<li>Navigation menus</li>
<li>Images and downloads</li>
<li>Responsive layout</li>
<li>404 page</li>
<li>Redirects</li>
</ul>




<h3 class="wp-block-heading">Administrative Testing</h3>




<ul class="wp-block-list">
<li>WordPress login</li>
<li>Post editing</li>
<li>Media uploads</li>
<li>Plugin settings</li>
<li>Theme customization</li>
<li>User management</li>
<li>Scheduled tasks</li>
</ul>




<h3 class="wp-block-heading">Business Function Testing</h3>




<ul class="wp-block-list">
<li>Contact forms</li>
<li>Newsletter subscriptions</li>
<li>Account registration</li>
<li>Password reset emails</li>
<li>Checkout and payment flow</li>
<li>Booking forms</li>
<li>Customer dashboards</li>
<li>API integrations</li>
<li>Webhook delivery</li>
</ul>




<p class="wp-block-paragraph">Check the browser console, server logs, WordPress debug logs, and network requests. A page can appear normal while scripts, API calls, or background processes are failing silently.</p>




<h2 class="wp-block-heading">How to Reduce WordPress Migration Downtime</h2>




<p class="wp-block-paragraph">Zero downtime is difficult to guarantee for every website, but downtime can often be reduced to a very small window.</p>




<h3 class="wp-block-heading">Lower the DNS TTL in Advance</h3>




<p class="wp-block-paragraph">DNS Time to Live determines how long resolvers may cache a record. Lowering the TTL before migration can help changes propagate more quickly.</p>




<p class="wp-block-paragraph">This should be done before the migration window, not at the exact moment of cutover. Existing cached records will continue using the previous TTL until they expire.</p>




<h3 class="wp-block-heading">Perform an Initial Copy Before Cutover</h3>




<p class="wp-block-paragraph">Copy the website files and database to the destination in advance. This gives enough time to test the environment and resolve compatibility problems before the final switch.</p>




<h3 class="wp-block-heading">Freeze Content Changes Briefly</h3>




<p class="wp-block-paragraph">For dynamic websites, a short maintenance or content-freeze window may be necessary. During this period, editors should stop publishing and customers may need to be temporarily prevented from submitting transactions.</p>




<h3 class="wp-block-heading">Perform a Final Database Synchronization</h3>




<p class="wp-block-paragraph">After the initial copy and testing, export the latest database immediately before the DNS or proxy cutover. This minimizes the chance of losing recent activity.</p>




<h3 class="wp-block-heading">Keep the Old Server Available</h3>




<p class="wp-block-paragraph">Do not cancel the original hosting account as soon as DNS is changed. Some visitors may still reach the old server due to DNS caching. The source environment should remain available until propagation is complete and the new website has been monitored.</p>




<h2 class="wp-block-heading">DNS Migration: More Than Changing One Record</h2>




<p class="wp-block-paragraph">DNS errors are responsible for many migration failures. A domain may have records for the website, email, verification services, subdomains, APIs, and third-party platforms.</p>




<p class="wp-block-paragraph">Before making changes, export or document the complete DNS zone. Important records may include:</p>




<ul class="wp-block-list">
<li>A records</li>
<li>AAAA records</li>
<li>CNAME records</li>
<li>MX records</li>
<li>TXT records</li>
<li>SPF records</li>
<li>DKIM records</li>
<li>DMARC records</li>
<li>Domain verification records</li>
<li>Service-specific subdomains</li>
</ul>




<p class="wp-block-paragraph">If only the web server is changing, there may be no reason to modify email records. Accidentally replacing the entire DNS zone with a simplified version can interrupt business email even when the website itself works correctly.</p>




<h2 class="wp-block-heading">SSL and HTTPS After Migration</h2>




<p class="wp-block-paragraph">The destination server must have a valid SSL certificate for the production domain. The certificate should cover all required hostnames, such as the root domain and the <code>www</code> version.</p>




<p class="wp-block-paragraph">After enabling HTTPS, verify that:</p>




<ul class="wp-block-list">
<li>The certificate is valid and not expired</li>
<li>The certificate matches the domain</li>
<li>HTTP redirects to HTTPS correctly</li>
<li>The root and www versions behave consistently</li>
<li>No redirect loop exists</li>
<li>Images and scripts load through HTTPS</li>
<li>CDN resources use secure URLs</li>
</ul>




<p class="wp-block-paragraph">Mixed-content errors should be corrected rather than hidden. They usually indicate that some database values, theme settings, CSS files, or external resources still reference HTTP URLs.</p>




<h2 class="wp-block-heading">Email and Contact Form Verification</h2>




<p class="wp-block-paragraph">A website migration is not complete until email functionality is tested.</p>




<p class="wp-block-paragraph">WordPress may rely on the local server mail function, SMTP authentication, a transactional email provider, or an external mailbox service. Each method has different requirements.</p>




<p class="wp-block-paragraph">Test at least the following:</p>




<ul class="wp-block-list">
<li>Contact form delivery</li>
<li>Administrator notifications</li>
<li>Password reset emails</li>
<li>WooCommerce order emails</li>
<li>Registration emails</li>
<li>Newsletter confirmation messages</li>
<li>SMTP authentication</li>
</ul>




<p class="wp-block-paragraph">Also confirm that SPF, DKIM, and DMARC records remain valid. Successful message submission does not always mean successful inbox delivery.</p>




<h2 class="wp-block-heading">Migrating a WooCommerce Website</h2>




<p class="wp-block-paragraph">WooCommerce migrations require additional care because the database may change continuously. Orders, inventory, customer records, carts, subscriptions, refunds, and payment events can be created during the migration window.</p>




<p class="wp-block-paragraph">A WooCommerce migration plan should include:</p>




<ul class="wp-block-list">
<li>A clearly defined cutover time</li>
<li>A short checkout freeze if required</li>
<li>A final database synchronization</li>
<li>Payment gateway testing</li>
<li>Webhook testing</li>
<li>Order email verification</li>
<li>Inventory validation</li>
<li>Subscription and scheduled-payment checks</li>
<li>Customer login testing</li>
<li>Transaction log review</li>
</ul>




<p class="wp-block-paragraph">For high-traffic stores, a simple export-and-import workflow may not be sufficient. More advanced database synchronization or application-level migration techniques may be needed.</p>




<h2 class="wp-block-heading">Migrating WordPress to a New Domain</h2>




<p class="wp-block-paragraph">Changing the domain adds an SEO and application layer to the migration.</p>




<p class="wp-block-paragraph">In addition to moving the website, the process should include:</p>




<ul class="wp-block-list">
<li>Updating WordPress URLs</li>
<li>Replacing old domain references</li>
<li>Creating page-to-page 301 redirects</li>
<li>Updating canonical URLs</li>
<li>Updating XML sitemaps</li>
<li>Updating analytics properties</li>
<li>Updating Search Console records</li>
<li>Updating social profiles</li>
<li>Updating payment callbacks</li>
<li>Updating API allowlists</li>
<li>Updating email addresses where required</li>
</ul>




<p class="wp-block-paragraph">A blanket redirect from every old URL to the new homepage is not a good migration strategy. Each important old URL should redirect to its closest equivalent on the new domain.</p>




<h2 class="wp-block-heading">Protecting SEO During WordPress Migration</h2>




<p class="wp-block-paragraph">A hosting migration that keeps the same domain should normally have limited SEO impact when implemented correctly. A domain change or large structural change requires more planning.</p>




<p class="wp-block-paragraph">Important SEO checks include:</p>




<ul class="wp-block-list">
<li>Preserving existing URL structures</li>
<li>Maintaining page titles and metadata</li>
<li>Keeping canonical tags correct</li>
<li>Preserving structured data</li>
<li>Checking robots.txt</li>
<li>Checking XML sitemaps</li>
<li>Verifying redirects</li>
<li>Removing accidental noindex directives</li>
<li>Checking HTTP status codes</li>
<li>Monitoring crawl errors</li>
<li>Monitoring organic traffic after launch</li>
</ul>




<p class="wp-block-paragraph">Staging environments are often configured with <code>noindex</code> rules to prevent search engines from indexing them. That setting must not be carried into production accidentally.</p>




<p class="wp-block-paragraph">It is also important to remove temporary password protection, development redirects, and staging-specific canonical URLs before launch.</p>




<h2 class="wp-block-heading">Performance Optimization After Migration</h2>




<p class="wp-block-paragraph">A migration provides a good opportunity to improve performance, but optimization should be performed methodically.</p>




<p class="wp-block-paragraph">Start by confirming that the website is stable. Then review:</p>




<ul class="wp-block-list">
<li>Page caching</li>
<li>Object caching</li>
<li>PHP OPcache</li>
<li>Database performance</li>
<li>Image compression</li>
<li>WebP or AVIF delivery</li>
<li>CDN integration</li>
<li>Browser caching</li>
<li>Gzip or Brotli compression</li>
<li>Slow plugins</li>
<li>Unused scripts and styles</li>
<li>External API delays</li>
</ul>




<p class="wp-block-paragraph">Do not enable several caching systems at the same time without understanding how they interact. Duplicate caching layers can create stale pages, login problems, checkout issues, and difficult-to-diagnose behavior.</p>




<h2 class="wp-block-heading">Security Checks After Migration</h2>




<p class="wp-block-paragraph">The new environment should be reviewed before the migration is considered complete.</p>




<p class="wp-block-paragraph">Post-migration security checks should include:</p>




<ul class="wp-block-list">
<li>Removing temporary migration files</li>
<li>Removing exported database archives</li>
<li>Deleting unused administrator accounts</li>
<li>Changing temporary credentials</li>
<li>Confirming file ownership and permissions</li>
<li>Disabling directory listing</li>
<li>Reviewing firewall rules</li>
<li>Enabling brute-force protection</li>
<li>Updating WordPress, themes, and plugins</li>
<li>Reviewing backup access</li>
<li>Protecting configuration files</li>
<li>Confirming malware scanning</li>
</ul>




<p class="wp-block-paragraph">Database exports and full-site archives often contain sensitive information. They should not remain publicly accessible inside the website directory after migration.</p>




<h2 class="wp-block-heading">WordPress Migration Plugins vs Manual Migration</h2>




<p class="wp-block-paragraph">Both methods are valid. The right choice depends on the website.</p>




<h3 class="wp-block-heading">Migration Plugins</h3>




<p class="wp-block-paragraph">Migration plugins are convenient for small and medium-sized websites. They can package files and databases, update URLs, and simplify restoration.</p>




<p class="wp-block-paragraph">However, they may encounter:</p>




<ul class="wp-block-list">
<li>File size limits</li>
<li>PHP execution time limits</li>
<li>Memory limits</li>
<li>Hosting restrictions</li>
<li>Archive extraction failures</li>
<li>Large database timeouts</li>
<li>Limited visibility into errors</li>
</ul>




<h3 class="wp-block-heading">Manual or Server-Level Migration</h3>




<p class="wp-block-paragraph">Manual migration provides more control and is often more reliable for large, customized, or business-critical websites.</p>




<p class="wp-block-paragraph">It also allows the migration specialist to inspect logs, control database operations, preserve permissions, perform incremental transfers, and troubleshoot individual layers separately.</p>




<p class="wp-block-paragraph">The tradeoff is that server-level migration requires stronger knowledge of Linux, databases, DNS, web servers, PHP, SSL, and WordPress internals.</p>




<h2 class="wp-block-heading">A Practical WordPress Migration Checklist</h2>




<h3 class="wp-block-heading">Before Migration</h3>




<ul class="wp-block-list">
<li>Audit the current website</li>
<li>Record PHP and database versions</li>
<li>List active integrations</li>
<li>Export DNS records</li>
<li>Lower DNS TTL if needed</li>
<li>Create and verify backups</li>
<li>Prepare the destination server</li>
<li>Install required PHP extensions</li>
<li>Create the destination database</li>
<li>Plan the maintenance window</li>
</ul>




<h3 class="wp-block-heading">During Migration</h3>




<ul class="wp-block-list">
<li>Copy WordPress files</li>
<li>Export and import the database</li>
<li>Update wp-config.php</li>
<li>Perform safe URL replacement</li>
<li>Verify permissions</li>
<li>Test using a hosts-file override</li>
<li>Check logs and browser errors</li>
<li>Test forms, logins, and transactions</li>
<li>Perform the final database sync</li>
<li>Update DNS</li>
</ul>




<h3 class="wp-block-heading">After Migration</h3>




<ul class="wp-block-list">
<li>Verify SSL</li>
<li>Test email delivery</li>
<li>Check redirects</li>
<li>Check sitemap and robots.txt</li>
<li>Clear all cache layers</li>
<li>Review application logs</li>
<li>Monitor uptime</li>
<li>Monitor search and analytics data</li>
<li>Keep the old server available temporarily</li>
<li>Remove migration archives</li>
<li>Confirm backup schedules</li>
</ul>




<h2 class="wp-block-heading">When Should You Hire a WordPress Migration Specialist?</h2>




<p class="wp-block-paragraph">A simple personal website may be moved successfully with a migration plugin. Professional assistance becomes more valuable when the website has operational or commercial importance.</p>




<p class="wp-block-paragraph">Consider hiring a specialist when:</p>




<ul class="wp-block-list">
<li>The website generates sales or leads</li>
<li>Downtime has a direct business cost</li>
<li>The database changes frequently</li>
<li>The site uses WooCommerce or memberships</li>
<li>The migration includes a domain change</li>
<li>Email services share the same DNS zone</li>
<li>The site contains custom plugins or code</li>
<li>The destination server requires manual configuration</li>
<li>The site has a large media library or database</li>
<li>You need SSL, DNS, redirects, and testing handled together</li>
</ul>




<p class="wp-block-paragraph">A professional migration should provide more than a copied website. It should include preparation, backups, testing, DNS coordination, SSL validation, post-migration verification, and a rollback plan.</p>




<h2 class="wp-block-heading">Final Thoughts</h2>




<p class="wp-block-paragraph">WordPress migration is a technical process, but the real objective is business continuity. Visitors should continue reaching the website, customers should continue completing transactions, administrators should retain access, and search engines should continue finding the same content.</p>




<p class="wp-block-paragraph">The safest migrations are rarely the fastest-looking ones. They are the migrations where the destination is prepared in advance, backups are verified, the website is tested before cutover, DNS is handled carefully, and the old environment remains available until the transition is confirmed.</p>




<p class="wp-block-paragraph">Whether the website is moving to a new hosting company, a VPS, a cloud server, or a new domain, a structured migration plan dramatically reduces the risk of downtime, missing data, and unexpected failures.</p>




<div class="wp-block-group" style="border-width:1px;border-radius:12px;margin-top:48px;padding-top:32px;padding-right:32px;padding-bottom:32px;padding-left:32px"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-6f60fb98 wp-block-group-is-layout-constrained">



<h2 class="wp-block-heading has-text-align-center">Need Help Migrating Your WordPress Website?</h2>




<p class="has-text-align-center wp-block-paragraph">A WordPress migration should not put your website, customer data, email delivery, or search rankings at risk. I provide complete WordPress migration services covering website files, databases, DNS, SSL, testing, redirects, and post-migration verification.</p>




<p class="has-text-align-center wp-block-paragraph">Your website will be prepared, transferred, tested, and launched through a controlled migration process designed to minimize downtime and prevent avoidable disruption.</p>




<div class="wp-block-buttons is-content-justification-center is-layout-flex wp-container-core-buttons-is-layout-fe48e5de wp-block-buttons-is-layout-flex">



<div class="wp-block-button has-custom-width wp-block-button__width-75">
<a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/services/product/development-it-wordpress-website-migration-with-minimal-downtime-2077830746744627334" target="_blank" rel="noopener sponsored" style="border-radius:8px;padding-top:14px;padding-right:24px;padding-bottom:14px;padding-left:24px">View My WordPress Migration Service on Upwork</a>
</div>


</div>


</div></div>


<p>The post <a href="https://john-nessime.com/blog/wordpress/wordpress-migration-without-downtime/">WordPress Migration Without Downtime: A Practical Guide to Moving Your Website Safely</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
