Migrating a Small Site to a Self-Hosted CMS Without Downtime Panic
Published 2026-06-14 · Updated 2026-07-31 · 16 min read · By Ben Liu
Content inventory, redirect maps, staging, and cutover checklists for blogs and brochure sites moving off SaaS CMS.
Inventory before themes
Export posts, pages, media, authors, and URLs. Count broken links. Note forms and comment systems. Theme shopping before inventory is how migrations slip by months.
Prefer to start every CMS migration with a plain CSV: slug, title, published date, author, canonical URL, media count, and whether the page has embedded forms or third-party widgets. That spreadsheet becomes the contract between "what we had" and "what we owe visitors after cutover." If you cannot produce it from the old host's export, you are not ready to pick a theme.
Inventory checklist
- Posts and pages with full URL paths preserved
- Media library with original filenames and alt text
- Author accounts and byline metadata
- Categories, tags, and custom taxonomies
- Comments or discussion embeds (note vendor lock-in)
- Contact forms, newsletter signups, and webhook endpoints
- Hard-coded embeds (maps, calendars, donation buttons)
- ads.txt, robots.txt, and sitemap URLs if monetized
Build a redirect map
Old URLs are equity and bookmarks. Generate a spreadsheet of source → destination. Keep it in version control next to the CMS config. Test redirects on staging with a script, not by clicking ten links manually.
Redirects belong in the reverse proxy config or the CMS plugin layer — pick one owner. Mixing both without documentation guarantees loops and silent misses. We version the redirect file next to the compose project under `/srv/compose/cms/` so rebuilds do not depend on someone's browser bookmarks.
# smoke-test redirects from staging (adjust host and map file)
while IFS=, read -r src dst; do
code=$(curl -s -o /dev/null -w '%{http_code}' -L "https://staging.example.com${src}")
echo "${src} -> ${code} (expect 301/308 to ${dst})"
done < redirects.csvStaging that mirrors production
Staging needs the same reverse proxy behavior, object sizes, and search plugins. "Works on localhost" is not a cutover criterion. Import a full media set once to discover upload limits early.
Our staging clone runs on a Proxmox VM with the same Caddy timeout values, the same PHP memory limits, and the same object-storage backend as production. A 200 MB PDF that fails on staging will fail on cutover night — better to learn that on a Tuesday.
# excerpt: cms stack on internal + edge networks
services:
caddy:
image: caddy:2.8.4
ports: ["443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
networks: [edge, internal]
cms:
image: ghost:5.94.1
env_file: .env
volumes:
- cms_content:/var/lib/ghost/content
networks: [internal]
db:
image: mysql:8.0.36
volumes: [db_data:/var/lib/mysql]
networks: [internal]
networks:
edge:
internal:
internal: true
volumes:
cms_content:
db_data:Cutover window
Freeze writes on the old host, take a final export, import to the new host, flip DNS or proxy routing, watch error logs for an hour. Keep the old host read-only for a week as rollback.
Write the cutover script before the day arrives. Ours looks like: lower TTL 24 hours ahead → final export → import → run redirect smoke test → flip DNS A record or Caddy upstream → watch 404 rate for 60 minutes → send one test form submission. If any step lacks a rollback note, you are improvising on production traffic.
Cutover failure modes
- **Mixed content after TLS flip:** theme hard-coded `http://` asset URLs break silently in browsers. Search rendered HTML for `http://` before go-live.
- **Timezone skew on scheduled posts:** imported GMT timestamps publish "in the future" on the new host. Spot-check three scheduled posts.
- **Search index stale:** full-text search plugins need a reindex after import. Verify a known phrase before announcing success.
- **Image hotlink breakage:** CDN URLs in old content 404 when the SaaS CDN token expires. Bulk-replace or proxy during transition week.
Forms and mail
Contact forms fail silently when SMTP is wrong. Send a test message to yourself during cutover. Document the provider and credentials location in the runbook.
SMTP credentials live in `.env`, not in the CMS admin UI screenshot folder. We record provider, port, TLS mode, and which mailbox receives form failures. A form that accepts submissions but never delivers email is worse than a 503 — it looks healthy while leads vanish.
SEO and ads housekeeping
Update Search Console property, sitemap ping, ads.txt if needed, and canonical tags. If you monetize, confirm ad units still render on article templates after the theme change.
Re-submit the sitemap after cutover and watch Coverage for spike in "Submitted URL not found." Fix the top ten 404s before touching typography. Ad units often break when article templates change column widths — load three monetized pages on mobile and desktop before closing the migration ticket.
Post-migration week
Watch 404 logs daily. Fix high-traffic misses first. Only then tweak typography. Stability beats polish in week one.
Lab notes from careful ops practice
We migrated a 180-post Ghost export to a self-hosted instance on a review host in one evening — but only because inventory and redirects were done the prior week. The actual import took 40 minutes; redirect fixes took two days as we discovered `/blog/` vs `/posts/` path differences the export obscured.
Lesson that stuck: keep the old SaaS site read-only behind basic auth for seven days, not deleted. One bad redirect map was fixable because the old URLs still resolved.
Compose pin: record CMS and database tags in `STACK.md`. A `:latest` pull on migration week is how you debug schema migrations and content imports simultaneously — avoid it.
Related reading
See docker compose hardening, reverse proxy TLS basics, and backup strategy for self-hosted apps before you delete the old host.
Explore more
Related guides
- Self-Hosted RSS: Feed Readers, Sync, and OPML Hygiene
When to self-host FreshRSS or Miniflux, how to keep OPML portable, and fetch habits that respect publishers without turning RSS into another inbox.
- Self-Hosted Wiki for Households and Tiny Teams
Pick BookStack vs Wiki.js vs Outline for the job, structure pages people will actually use, and back up content before the wiki becomes the only copy.
- Ollama on a Homelab: Local LLMs Without Melting the Rack
Operator guide to running Ollama at home — install paths, Docker, model disk gravity, GPU vs CPU, API exposure, and a sane first weekend with Open WebUI.
- Updating Self-Hosted Apps Without Fear
A repeatable update ritual: changelog, snapshot, migrate, verify, and roll back — with lab notes from upgrade nights.