
Hi folks,
My blog lived at blog.hoja.in since 2016, on its own WordPress installation, next to the TYPO3 site on hoja.in. This year, together with the TYPO3 upgrade, I moved it to hoja.in/blog. One domain, one place for visitors, and the blog’s links and authority count for the main site instead of a separate host.
The move itself is not complicated, but WordPress stores its own URL in many places, some of them serialized, some JSON-encoded. Here is how I did it, with the commands from my runbook.
Step 1: put the files in the right place
The TYPO3 project has its web root in public/, so WordPress went into public/blog/. Two things make this work next to TYPO3:
- WordPress needs its own
.htaccessinpublic/blog/withRewriteBase /blog/. For Apache, the rewrite rules of the deepest directory win, so requests below/blog/go to WordPress’index.php, not to TYPO3’s. - The table prefix stays as it was (
myblog_in my case). Changing it during a move is an extra risk for no gain.
# public/blog/.htaccess
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /blog/
RewriteRule ^index.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /blog/index.php [L]
</IfModule>
# END WordPressStep 2: siteurl and home
WordPress has two options for its address: siteurl (where the WordPress files are) and home (the public address). When both are wrong, you cannot even log in to fix them, because wp-admin redirects to the old host. I set them as constants in the config instead, which override the database values:
// wp-config-production.php (on the server, not in git)
define('WP_HOME', 'https://hoja.in/blog');
define('WP_SITEURL', 'https://hoja.in/blog');
define('DISALLOW_FILE_EDIT', true);My wp-config.php only decides which file to load: wp-config-ddev.php when IS_DDEV_PROJECT is set, otherwise wp-config-production.php. The local file sets the same constants to https://hoja.in/blog. So the same code runs locally and on the server, and no password is in git.
Step 3: replace the old URLs in the database
The constants fix where WordPress thinks it lives. They do not fix the thousands of old URLs inside post content, image links, widget settings and plugin options. Never do this with a plain SQL REPLACE(): many values are PHP-serialized, where the string length is part of the data. s:22:"http://blog.hoja.in/x" with a longer URL inside and the old length in front is broken, and WordPress silently drops the whole option. wp search-replace unserializes, replaces and serializes again.
Always run it with --dry-run first. On my local copy, before exporting it for production:
wp search-replace 'https://hoja.in/blog' 'https://hoja.in/blog'
--all-tables-with-prefix --precise --dry-run --report-changed-only Table Column Replacements Type
myblog_options option_value 4 PHP
myblog_posts post_content 10 PHP
myblog_posts guid 50 PHP
myblog_yoast_indexable permalink 26 PHP
myblog_yoast_indexable twitter_image 3 PHP
myblog_yoast_indexable open_graph_image 3 PHP
Success: 96 replacements to be made.Read this table. Does the number of posts look right? Is a table missing that you expected? --all-tables-with-prefix includes plugin tables like Yoast’s, which a plain wp search-replace would skip because they are not registered core tables. --precise makes it use PHP for every row instead of SQL where it can, slower but safe for serialized data. Then run the same command without --dry-run. If you start from the production dump instead, replace both schemes:
wp core update-db
wp search-replace 'https://blog.hoja.in' 'https://hoja.in/blog' --all-tables-with-prefix --precise
wp search-replace 'http://blog.hoja.in' 'https://hoja.in/blog' --all-tables-with-prefix --preciseThe guid column
You see guid in the list above. The WordPress documentation on changing the site URL is very clear that the GUID should not be changed, because feed readers use it to recognise posts they already know. --skip-columns=guid does that. My runbook does not skip it; if I did it again, I would add the option.
The URLs that hide in JSON
After the replacement I searched once more for the bare old host name, in a dry run:
wp search-replace 'blog.hoja.in' 'x' --all-tables-with-prefix --dry-run --report-changed-onlyThree rows were left, in myblog_yoast_indexable.open_graph_image_meta. That column holds JSON, and JSON escapes slashes: the value contains http://blog.hoja.in/wp-content/..., so a search for http://blog.hoja.in never matches it. For Yoast the clean fix is to rebuild its index after the move, which is a step in my runbook anyway:
wp yoast index --reindex --skip-confirmationFor other plugins that store JSON, search for the host name alone, look at the rows, and replace the escaped form explicitly if needed.
Step 4: 301 from the old subdomain
Every old link on other sites, in search results and in my own old posts points to blog.hoja.in. Those must keep working, permanently. In the document root of the subdomain I put only one .htaccess:
RewriteEngine On
RewriteRule ^(.*)$ https://hoja.in/blog/$1 [R=301,L]It keeps the path, so blog.hoja.in/some-post/ lands on hoja.in/blog/some-post/ in one hop. The permalink structure (/%postname%/) stays the same, so no per-post redirects are needed. If you change the permalink structure at the same time, do it in a separate step; two changes at once are hard to debug.
Step 5: check what search engines will see
After the switch, I check with curl, not with the browser (the browser cache lies after redirects):
# one hop, permanent, path kept
curl -sI https://blog.hoja.in/some-post/ | grep -iE '^(HTTP|location)'
# canonical of a post points to the new address
curl -s https://hoja.in/blog/some-post/ | grep -o '<link rel="canonical"[^>]*>'
# the sitemap lists only new URLs
curl -s https://hoja.in/blog/sitemap_index.xml | grep -c 'blog.hoja.in'The last command must print 0. Yoast generates the canonical and the sitemap from its indexables, which is one more reason to reindex. Finally, robots.txt of the main site now lists both sitemaps, the TYPO3 one and the blog one:
Sitemap: https://hoja.in/sitemap.xml
Sitemap: https://hoja.in/blog/sitemap_index.xmlRollback
Keep the old database dump and the old blog folder until you are sure. The rollback in my runbook is three lines: restore the dump, restore the folder, remove the subdomain redirect. Because the old installation was never changed in place, that really works.
Moving a blog is one of those jobs where a dry run costs five minutes and a mistake costs your search traffic. Use the dry run. Cheers!