Skip to content
/dev/zeyu
Go back

Migrating from WordPress to Astro + Memos + Caddy

Why Leave WordPress?

WordPress served me well for years, but it was starting to feel like overkill for what is essentially a personal blog. The PHP runtime, MySQL database, constant plugin updates, and security patches — all for serving mostly static content. I wanted something lighter.

The new stack:

The idea: split the blog into two. Tech posts become static markdown in Astro. Diary entries, travel logs, and personal notes go into Memos — a better fit for short-form content with inline images.

Step 1: Exporting WordPress Content

The XML Export

WordPress has a built-in export tool. Go to wp-admin → Tools → Export → All Content and download the XML file. This gives you a WXR (WordPress eXtended RSS) file containing all posts, pages, categories, tags, and image metadata.

My export had:

Converting XML to Markdown

I used wordpress-export-to-markdown to convert the XML into markdown files with frontmatter:

npx wordpress-export-to-markdown \
  --input comet.WordPress.2026-03-06.xml \
  --output wp-content \
  --save-images all \
  --wizard false \
  --post-folders true \
  --prefix-date true \
  --include-time true

All 511 posts converted successfully to markdown with frontmatter (title, date, categories, tags).

The Image Problem

The tool tries to download images by fetching them from your live WordPress site. But my site returned 403 Forbidden for every image — likely a hotlink protection rule I had forgotten about.

The fix: bypass HTTP and grab the uploads folder directly via SSH:

scp -i kasakun.pem -r \
  kasakun@kasakun.xyz:/home/kasakun/wordpress/wordpress-nginx-docker/wordpress/wp-content/uploads/ \
  ./wp-uploads/

Lesson learned: Always grab your images via direct file access (SSH/SFTP), not HTTP.

Step 2: Matching Images to Posts

WordPress generates multiple thumbnail sizes for each image (e.g., img_1573-225x300.jpg, img_1573-768x1024.jpg). The markdown files reference these thumbnails, but the filenames in wp-uploads/ include an extra -scaled-1 suffix.

I wrote a script (match-images.cjs) to index all files in wp-uploads/, then walk through each post’s markdown to find image references and copy the matching files into each post’s images/ folder.

Step 3: Splitting Content — Astro vs Memos

Tech posts stayed as markdown files in Astro’s src/data/blog/ directory. Everything else (diary, travel, personal notes) went to Memos via its API.

The Memos Migration Script

The migration script (migrate-to-memos.cjs) does the following:

  1. Reads each post’s markdown and frontmatter
  2. Filters by category — only diary/travel/fun/nana posts go to Memos
  3. Uploads images via the Attachments API (POST /api/v1/attachments) with base64-encoded content
  4. Rewrites image references in the markdown to point to Memos attachment URLs (/file/attachments/{id}/{filename})
  5. Creates the memo with the rewritten content and attached images

Key gotchas:

Running the Migration

Since Memos was publicly accessible on memos.kasakun.xyz, I ran the migration directly from my local machine:

node migrate-to-memos.cjs https://memos.kasakun.xyz <ACCESS_TOKEN>

Result: 489 memos migrated with 188 images, 5 tech posts correctly skipped, 0 failures.

Step 4: Infrastructure Setup

Docker Compose

The entire stack runs in two containers:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./site:/srv/site
      - caddy_data:/data
      - caddy_config:/config

  memos:
    image: neosmemo/memos:stable
    restart: unless-stopped
    volumes:
      - ./memos:/var/opt/memos
    expose:
      - "5230"

Caddyfile

Caddy handles both domains with automatic HTTPS:

kasakun.xyz {
    root * /srv/site
    file_server
    encode gzip

    redir /wp-admin* / permanent
    redir /wp-login* / permanent
    redir /wp-content* / permanent
}

memos.kasakun.xyz {
    reverse_proxy memos:5230
}

The old WordPress URLs (/wp-admin, /wp-login, /wp-content) redirect to the homepage.

CI/CD with GitHub Actions

On every push to main, GitHub Actions builds the Astro site and deploys it to the VM via rsync over SSH:

name: Deploy to Azure VM
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm run build
      - name: Deploy to VM
        uses: easingthemes/ssh-deploy@main
        with:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          REMOTE_HOST: ${{ secrets.VM_HOST }}
          REMOTE_USER: ${{ secrets.VM_USER }}
          SOURCE: dist/
          TARGET: ~/blog/deploy/site/
          ARGS: "-avz --delete"

Three GitHub secrets needed: SSH_PRIVATE_KEY, VM_HOST, VM_USER.

One gotcha: the site/ directory on the VM was created by Docker as root, so the first rsync failed with permission denied. Fix: sudo chown -R $USER:$USER ~/blog/deploy/site/.

The Result


Share this post on:

Previous Post
Blogging with AI
Next Post
WordPress Setup and SSL