The site looked about ninety per cent right, which is somehow worse than looking broken.
Layout fine, images fine. But every heading had dropped back to a fallback system font, and every icon had turned into one of those little empty squares. Open the console and it was a wall of red:
Access to font at 'https://example.com/wp-content/themes/.../fonts/icons.woff2' from origin 'https://www.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Note the two hostnames. The page came from www.example.com. The font was being fetched from example.com. Same server, same WordPress, different origin as far as the browser is concerned.
This was the very first site through the fleet migration, the pilot, so it’s also the site that taught me most of the things I now check. (Fleet migration = moving a pile of WordPress sites off Bitnami Lightsail onto AWS’s managed blueprint. The pillar post has the overview.)
Why only the fonts
This is the bit I like, in hindsight.
Browsers don’t treat every cross-origin request the same way. Images, scripts, stylesheets: those load from another host without complaint unless you explicitly ask for CORS. Fonts are different. The CSS spec says @font-face fetches go out in CORS mode, so a font from another origin needs the server to answer with Access-Control-Allow-Origin, and mine didn’t, because it had never needed to.
So the wrong hostname had leaked into every asset URL on the page. Only fonts made a noise about it. Everything else loaded from the wrong place quietly.
Where the wrong hostname came from
The launch script that sets up each new server pins a canonical domain into wp-config.php. Roughly this shape:
define( 'CANONICAL_DOMAIN', 'example.com' ); $_SERVER['HTTP_HOST'] = CANONICAL_DOMAIN; define( 'WP_HOME', 'https://' . CANONICAL_DOMAIN ); define( 'WP_SITEURL', 'https://' . CANONICAL_DOMAIN ); define( 'WP_CONTENT_URL', 'https://' . CANONICAL_DOMAIN . '/wp-content' );
That’s deliberate. Pinning it means WordPress generates the same URLs whatever Host header a request turns up with, so you don’t get pages built for the raw IP or for some stale alias. The HTTP_HOST override is the strong part, and here it was the problem. Every URL WordPress built, including the Link preload headers and every enqueued stylesheet and font, used whatever that constant said.
For the pilot, it said the bare domain. The site had historically lived on www.
How I made it worse first
The SSL tooling on these boxes takes a list of hostnames per site, and the first one becomes Apache’s ServerName. The rest become aliases that 301 to it. I’d put the bare domain first. When I realised the site had always been www, I reissued with www first.
Two things went sideways at once.
The SSL manager keeps the old vhost around for a 48-hour grace period after a reissue. So now two vhosts answered for the same names, and Apache picks the alphabetically-first config file when that happens. Requests were landing on whichever one won. That one’s easy to fix, if annoying to diagnose: disable the old site config and reload Apache straight after reissuing.
The second thing was wp-config.php, which still said bare domain. Visitors now arrived on www, WordPress built every asset URL for the bare domain, and the fonts fell over. Fixed by editing the constant on the server:
sudo sed -i "s/define( *'CANONICAL_DOMAIN', *'example\.com' *)/define( 'CANONICAL_DOMAIN', 'www.example.com' )/" /path/to/wp-config.php
Ugly, but done. The real question was how to stop the next fifty-odd sites doing the same.
Attempt one: ask the old server
Obvious idea. The old server knows what the site is called. It’s in the database, wp_options.siteurl. Read that over SSH during bootstrap and use it as the canonical.
That worked right up until a site where it very much didn’t. The old server’s siteurl was a completely different domain, left over from whoever had used that server before. Nobody had noticed for years, because Bitnami’s stock wp-config.php sets WP_HOME and WP_SITEURL dynamically from the incoming HTTP_HOST. Whatever the database said got overridden on every request. The site worked, so the value was invisible.
My pipeline read that fossil, trusted it and baked the wrong domain into the new server.
That’s the lesson I keep coming back to from this whole job. A value that’s been overridden at runtime for years isn’t data. You can’t learn anything from it except that nothing ever read it.
Attempt two, briefly: always www
I then tried “just prefix www“. This lasted about as long as it took to meet a site that canonicalises on the bare domain, and one on a subdomain, where www.portal.example.com is simply wrong.
What stuck
Dull, and it works. The canonical for each site is written down by a human in the site’s config entry, and bootstrap uses exactly what’s written there. No inference, no prefix magic. You can override it on the command line if you need to.
To keep the human honest, there’s a small probe script I run before each batch. For every site it follows the redirect chain from both the bare and www forms and reports where you actually end up:
curl -sIL -m 10 -o /dev/null -w '%{http_code} %{url_effective}\n' https://example.com/
curl -sIL -m 10 -o /dev/null -w '%{http_code} %{url_effective}\n' https://www.example.com/
If both land on https://www.example.com/, that’s the canonical, and if my config says otherwise the script tells me what to change. That’s the live site’s own opinion of its name, straight from the running server, which is the only source I’ve found that doesn’t lie.
Takeaways, such as they are
When a browser blocks fonts but not images, look for the same site being addressed under two hostnames. It’s almost never a missing CORS header you should add. Adding Access-Control-Allow-Origin would have “fixed” the symptom and left every other asset quietly coming from the wrong host.
And when you automate a decision a human used to make, check what the human was actually looking at. It wasn’t the database.
