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:
- Astro (with Astro Paper theme) — static site generator for tech posts, all content in markdown
- Memos — a lightweight, self-hosted memo hub for diary entries and short notes
- Caddy — reverse proxy with automatic HTTPS via Let’s Encrypt
- Azure VM — single box running everything in Docker
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:
- 497 posts
- 10 pages
- 232 attached images
- Categories: Diary, Fun, Nana, Tech, Travel
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:
- Reads each post’s markdown and frontmatter
- Filters by category — only diary/travel/fun/nana posts go to Memos
- Uploads images via the Attachments API (
POST /api/v1/attachments) with base64-encoded content - Rewrites image references in the markdown to point to Memos attachment URLs (
/file/attachments/{id}/{filename}) - Creates the memo with the rewritten content and attached images
Key gotchas:
- Attachments, not Resources — Memos’ API uses
/api/v1/attachments, not/api/v1/resources. The response gives you anamelikeattachments/EBfjNFXEKAZfLFvCQz5tan. - Inline image URL format — For images to render inline, the URL must be
/file/attachments/{id}/{filename}, not/o/attachments/{id}. The/o/path serves the file but Memos’ markdown renderer doesn’t recognize it. - Content limit is in bytes, not characters — Memos has an 8192 limit, but it counts UTF-8 bytes. Chinese characters are 3 bytes each, so a 6800-character post can be 19000+ bytes. The script splits long posts at paragraph boundaries while respecting the byte limit.
- Special characters create false tags — Any
#in the content becomes a Memos tag. I had an old post with Enigma cipher output full of#characters. Wrapping it in code fences (triple backticks) prevented this.
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
- kasakun.xyz — fast static Astro site for tech posts
- memos.kasakun.xyz — Memos for diary, travel, and personal notes with inline images
- Deployment —
git pushand it’s live in ~60 seconds - No PHP, no MySQL, no WordPress updates — just static files and a single SQLite database for Memos