こんにちは、ネットアシスト運用チームのhaokiです。
WordPressで作ったサイトでも、記事を読んでもらうだけなら、公開側にPHPもデータベースも要りません。
お店に例えるなら、厨房(WordPress)は奥に引っ込めて、できあがった料理を並べた棚(静的HTML)だけをお客さんに見てもらう形です。
今回は、これをさくらのクラウドで試してみました。記事はWordPressで書き、読者に見せるのはAstroで作った静的HTMLです。配信には、オブジェクトストレージとウェブアクセラレータを使います。
ゴールは、WordPressのサーバを止めても、サイトが表示され続けること。一番分かりやすい証明なので、本当に止めてみます。
途中で何度かつまずいたので、そこも含めて書いていきます。
検証環境
| 項目 | 内容 |
|---|---|
| WordPressのサーバ | さくらのクラウド(AlmaLinux、Apache・PHP・MariaDB) |
| 静的化 | Astro |
| 保存と配信 | オブジェクトストレージ(東京第1サイト)+ウェブアクセラレータ |
| 操作端末 | WSL2 |
お題:WordPressを止めても表示されるか
画像付きの記事も含めて、WordPressのサーバを止めた状態で、トップページと記事ページが表示されれば成功です。curlでHTTP 200が返るかどうかで、すぐ判定できます。
構成

変更前は、読者がWordPressのサーバに直接アクセスしていました。変更後はWordPressを非公開にして、REST APIで取り出した記事をAstroで静的HTMLにします。読者が見るのは、オブジェクトストレージに置いたファイルをウェブアクセラレータが配信したものです。
WordPressを用意して、REST APIで記事を取り出す
WordPressは、さくらのクラウドにAlmaLinuxのサーバを立てて、Apache・PHP・MariaDBで普通に入れました。ここは一般的な手順なので省略します。サンプル記事は3件で、うち1件は画像付きです。
WordPressには、REST APIが最初から入っています。まずは手元から、記事が取れるか見てみます。
curl -s "http://<WordPressのIP>/?rest_route=/wp/v2/posts&_fields=slug,title"
/wp-json/... ではなく ?rest_route= を使っているのは、AlmaLinuxのApacheが初期設定だと .htaccess を読まず、前者が404になりがちだからです。
記事は取れました。ただ、中身を見て「ん?」となりました。
ハマりどころ1:日本語のスラッグが読めない文字列になる
スラッグ(URLの末尾の部分)が、こんな状態でした。日本語のタイトルで投稿すると、URLエンコードされた形で保存されるようです。
[{"slug":"%e7%94%bb%e5%83%8f%e4%bb%98%e3%81%8d%e3%83%86%e3%82%b9%e3%83%88", ...}, ...]
このままだとHTMLのファイル名にも使いづらいので、英数字に変えておきます。WP-CLIで一括変更しました。
WP="wp --path=/var/www/html --allow-root"
for id in $($WP post list --post_type=post --format=ids); do
$WP post update $id --post_name=post-$id
done
これで、post-7 や post-10 のような素直なスラッグになりました。
Astroで静的HTMLにする
ここからは手元のPCでの作業です。Astroでプロジェクトを作ります。テンプレートは一番小さい minimal で十分です。
npm create astro@latest wp-headless -- --template minimal --yes
cd wp-headless
用意するファイルは4つだけです。
まず astro.config.mjs です。build.format を file にすると、記事ページが posts/post-7.html のような1ファイルで出力されます。オブジェクトストレージに置くときに、この形のほうが素直に動きます。
import { defineConfig } from 'astro/config';
export default defineConfig({
build: { format: 'file' },
});
src/lib/wp.js は、REST APIから記事を取ってくるだけの関数です。
const WP_URL = import.meta.env.WP_URL;
export async function getPosts() {
const res = await fetch(`${WP_URL}/?rest_route=/wp/v2/posts&per_page=100&_fields=slug,title,content,date`);
return res.json();
}
src/pages/index.astro は記事一覧です。
---
import { getPosts } from '../lib/wp.js';
const posts = await getPosts();
---
<html lang="ja"><head><meta charset="utf-8"><title>ブログ</title></head>
<body>
<h1>ブログ</h1>
<ul>
{posts.map((p) => (
<li>{p.date.slice(0, 10)} <a href={`/posts/${p.slug}.html`} set:html={p.title.rendered} /></li>
))}
</ul>
</body></html>
src/pages/posts/[slug].astro は記事ページです。getStaticPaths で記事の数だけページを作ります。
---
import { getPosts } from '../../lib/wp.js';
export async function getStaticPaths() {
const posts = await getPosts();
return posts.map((p) => ({ params: { slug: p.slug }, props: { post: p } }));
}
const { post } = Astro.props;
---
<html lang="ja"><head><meta charset="utf-8"><title set:html={post.title.rendered} /></head>
<body>
<p><a href="/index.html">トップへ</a></p>
<h1 set:html={post.title.rendered} />
<p>{post.date.slice(0, 10)}</p>
<article set:html={post.content.rendered} />
</body></html>
ビルドします。
WP_URL=http://<WordPressのIP> npm run build
generating static routes
├─ /posts/post-10.html
├─ /posts/post-8.html
├─ /posts/post-7.html
├─ /index.html
dist/ にHTMLができました。npx astro preview で、ブラウザから確認できます。
ハマりどころ2:画像がWordPressのサーバを見に行ったまま
ビルドは通って、dist/ にHTMLができました。ただ、画像付きの記事のHTMLを見ると、本文中の画像がWordPressのサーバを直接指したままです。
<img src="http://<WordPressのIP>/wp-content/uploads/2026/10/400.jpg">
WordPressを止めると画像だけ表示されなくなるので、ビルドのあとに画像をダウンロードして、パスを書き換えるスクリプトを足します。scripts/localize-images.mjs として保存します。
// dist/ 内のHTMLから WP 上の画像URLを探し、ダウンロードして相対パスに書き換える
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const WP_URL = process.env.WP_URL.replace(/\/$/, '');
const re = new RegExp(WP_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(/wp-content/uploads/[^"\'\\s,)]+)', 'g');
for (const file of await readdir('dist', { recursive: true })) {
if (!file.endsWith('.html')) continue;
const path = join('dist', file);
const html = await readFile(path, 'utf8');
for (const [, p] of html.matchAll(re)) {
const dest = join('dist', p);
await mkdir(dirname(dest), { recursive: true });
const res = await fetch(WP_URL + p);
await writeFile(dest, Buffer.from(await res.arrayBuffer()));
console.log('downloaded', p);
}
await writeFile(path, html.replace(re, '$1'));
}
npm run build で一緒に動くようにしておきます。
npm pkg set scripts.build="astro build && node scripts/localize-images.mjs"
WP_URL=http://<WordPressのIP> npm run build
downloaded /wp-content/uploads/2026/10/400.jpg
書き換わったか確認します。
$ grep -o 'src="[^"]*"' dist/posts/post-10.html
src="/wp-content/uploads/2026/10/400.jpg"
IPアドレスが消えて、サイト内のパスになりました。これで dist/ の中身だけでサイトが完結します。WordPressを止める前に気づけて、よかったです。
オブジェクトストレージに置く
コントロールパネルでオブジェクトストレージのサイト(今回は東京第1サイト)を作り、その中にバケットを作ります。バケット名は haoki-headless-demo にしました。
あわせて、このバケットに読み書きできるアクセスキーを発行しておきます。画面に表示されるS3エンドポイントとリージョンも控えます。
| 項目 | 値 |
|---|---|
| S3エンドポイント | s3.tky01.sakurastorage.jp |
| リージョン | jp-east-1 |
S3互換なので、アップロードにはAWS CLIがそのまま使えます。
aws configure --profile sakura
# Access Key ID / Secret Access Key を入力、region は jp-east-1
EP=https://s3.tky01.sakurastorage.jp
aws s3 sync dist/ s3://haoki-headless-demo/ --delete --profile sakura --endpoint-url $EP
upload: dist/index.html to s3://haoki-headless-demo/index.html
upload: dist/posts/post-10.html to s3://haoki-headless-demo/posts/post-10.html
upload: dist/wp-content/uploads/2026/10/400.jpg to s3://haoki-headless-demo/wp-content/uploads/2026/10/400.jpg
...
これでファイルの置き場所はできました。
ウェブアクセラレータで配信する
ウェブアクセラレータでサイトを追加します。検証なので、独自ドメインは取らずにサブドメイン(*.user.webaccel.jp)で進めます。共有SSLが使えるので、HTTPSもそのまま使えます。
| 項目 | 設定 |
|---|---|
| ドメイン種別 | サブドメイン |
| リクエストプロトコル | httpsにリダイレクト |
| オリジン種別 | オブジェクトストレージ(バケット、エンドポイント、リージョンを指定) |
| ドキュメントインデクス | 有効 |
S3リージョンは、作成直後の画面で jp-north-1 になっていました。バケットは東京第1サイト(jp-east-1)にあるので、詳細画面から jp-east-1 に直しています。
これで設定は完了です。さっそくcurlで確認します。
ハマりどころ3:作ったのに404が返る
$ curl -sI https://<サブドメイン>.user.webaccel.jp/ | head -1
HTTP/2 404
トップページも記事ページも画像も、3つともすべて404でした。ちょっと焦りました。しかも返ってくるのはnginxの素っ気ないエラーページで、バケット側のエラーですらありません。リクエストがバケットまで届いていない、ということです。
原因は、サイトのステータスが「無効」のままだったことでした。ウェブアクセラレータは、サイトを追加しただけでは配信が始まりません。設定画面で「有効」に切り替える必要があります。

有効にしたら、あっさり200が返ってきました。ほっとしました。
$ curl -sI https://<サブドメイン>.user.webaccel.jp/ | head -1
HTTP/2 200
ドキュメントインデクスを有効にしているので、/ でもトップページの index.html が返ります。
結果:WordPressを止めても、サイトは表示された
ここまで来たら、WordPressのサーバを止めます。
$ ssh root@<WordPressのIP> 'shutdown -h now'
$ curl -m 5 -sI http://<WordPressのIP>/ || echo "WP停止を確認"
WP停止を確認
止まった状態で、公開側のURLにアクセスします。
$ curl -sI https://<サブドメイン>.user.webaccel.jp/ | head -1
HTTP/2 200
$ curl -sI https://<サブドメイン>.user.webaccel.jp/wp-content/uploads/2026/10/400.jpg | head -1
HTTP/2 200
トップページも画像も、問題なく返ってきました。止めたはずのサーバの向こうで、サイトが普通に表示されているのは、ちょっと不思議な光景です。
これはキャッシュが残っているから見えているわけではありません。ウェブアクセラレータのオリジンはオブジェクトストレージで、WordPressとはそもそもつながっていないからです。WordPressは記事を書くときとビルドするときにだけ動いていればよく、普段は止めておいても読者には関係ありません。
費用
公開側にかかるのは、オブジェクトストレージとウェブアクセラレータの料金です。ウェブアクセラレータにはプロジェクトごとの無償枠があるので、今回のような小さなサイトなら、実質かかりません。オブジェクトストレージも、バケットごとの基本料金の範囲に収まります。
細かい料金は変わることもあるので、最新の内容は、末尾の参考リンクから公式ページで確認してください。
WordPressのサーバは、止めている間はサーバ料金がかからない仕組みですが、ディスク代はかかり続けます。ここは忘れがちなので、気をつけたいところです。
向いているサイト、向いていないサイト
ブログやコーポレートサイトのように、記事を読ませるのが中心のサイトには向いています。一方で、問い合わせフォームやコメント、サイト内検索のような動的な機能は、静的化するとそのままでは動きません。外部サービスなどで置き換える必要があります。会員機能やECのように、サーバ側の処理が中心のサイトには向きません。
よかった点
公開側がシンプルになりました。読者がアクセスするのは静的ファイルとCDNだけなので、WordPressやPHPの脆弱性を外から突かれる心配が、ほとんどなくなります。管理画面は、社内や自宅のIPからだけ入れるように閉じておけば足ります。
作業も、思ったよりあっさりでした。ビルドして aws s3 sync するだけで、さくらのクラウド側の設定もコントロールパネルで済みました。
何より、WordPressを止めても表示が変わらないのは、見ていて安心感がありました。
まとめ
- 公開側は静的HTMLにして、WordPressは「記事を書く場所」にできる
- ハマったのは、日本語スラッグ、画像のリンク先、ウェブアクセラレータの「無効」ステータスの3つ
- WordPressを止めても、トップページも画像も200で返った
止めたサーバの向こうでサイトが普通に表示されているのを見ると、公開側に本当に必要なものは何だったのか、考えさせられます。守る場所が、公開側のサーバから、社内のWordPressとアップロード用のキーに絞られた、という見方もできそうです。
今回は手元で手動でビルドしましたが、WordPressで記事を公開したら自動でビルドとアップロードが走るようにすれば、書く人の運用はほとんど変わりません。そのあたりはまた別の記事で試してみます。
WordPressの運用負担やセキュリティ対策、さくらのクラウドへの移行についてご相談がある方は、ぜひネットアシストにご連絡ください。
それではまた!