TL;DR
- デプロイは rsync + SSH の素朴な構成で十分。CI/CD を組まなくても DRY-RUN + 確認プロンプトが守られていれば事故は起きにくい。
--deleteを使うかは 「rsync 先がスクリプトの専有領域か?」で決める。専有なら使う、subdomain dir や.user.iniと同居しているなら使わない。- Laravel 側は composer / migrate / config:cache まで含めて
ssh ... 'cd .../laravel && /usr/bin/php8.3 ...'で逐次実行。 - prod / dev の切り替えは
--env1 つで吸収する。VITE_*の build-time env もここで切る。
デプロイ構成の全体像
3 種類のデプロイがある:
| ターゲット | スクリプト | 流れ |
|---|---|---|
| フロント (Vite PWA) | deploy-app.sh | ローカル npm run build → dist/ → rsync → app.example.com/ |
| API (Laravel 13) | deploy-api.sh | rsync api/ → ~/api.example.com/laravel/ → ssh で composer + migrate + cache |
| apex LP | deploy-apex.sh | rsync deploy/apex/ → example.com/public_html/ (削除なし) |
すべて DRY-RUN 既定 / --apply で初めて動くという第 2 回の流儀を踏襲。
なぜ rsync + SSH なのか
Xserver は SSH を 10022 番ポートで許してくれている (鍵認証必須)。これが使えるなら CI/CD を組むより rsync + SSH の方がほぼ確実に速い:
- ローカルでビルドした成果物だけ送るので、サーバに Node.js / Composer / PHP の重い依存を入れずに済む
- CI/CD のセットアップが要らないので個人開発スケールに合う
- DRY-RUN がそのまま rsync
--dry-runで表現できるので作り込みが薄い
~/.ssh/config で SSH ホストを抽象化しておく:
Host myserver
HostName myserver.xsrv.jp
User myserver
Port 10022
IdentityFile ~/.ssh/xserver_myserver
スクリプトは ${XSERVER_SSH_HOST} を myserver として参照するので、サーバ固有情報をスクリプトから追い出せる。
フロント (deploy-app.sh)
ステップは 2 つ:
# 1. ビルド
export VITE_API_BASE_URL VITE_DEPLOY_ENV VITE_FEATURE_VOICE_INPUT
npm run build
# 2. 転送
rsync -avz --delete --exclude '.user.ini' --exclude 'models/' \
"${REPO_ROOT}/dist/" "${XSERVER_SSH_HOST}:~/${LP_DOMAIN}/public_html/app.example.com/"
--delete を使う
フロント側は 「dist/ の中身そのままが本番」が正解。--delete を使って古いハッシュ付き JS/CSS を消す。
ただし以下は除外:
.user.ini— Xserver が PHP 設定として全 doc root に置く hardlink。dist/には存在しないので--deleteで消すと PHP-FPM の挙動が壊れるmodels/— vosk の音声認識モデル (大きい静的リソース)。dist/に入れずサーバ上で別管理する方針
環境変数を build-time に流し込む
Vite は build 時の env を import.meta.env.VITE_* でバンドルに焼き込む。env ごとに既定値を切る:
case "${ENV_TARGET}" in
prod)
VITE_API_BASE_URL_DEFAULT="https://api.example.com"
VITE_DEPLOY_ENV_DEFAULT="prod"
VITE_FEATURE_VOICE_INPUT_DEFAULT="false" # 慎重リリース
;;
dev)
VITE_API_BASE_URL_DEFAULT="https://dev-api.example.com"
VITE_DEPLOY_ENV_DEFAULT="dev"
VITE_FEATURE_VOICE_INPUT_DEFAULT="true" # 実機検証
;;
esac
# 外部から渡された env があればそれを優先 (`:=` で既定値挿入)
: "${VITE_API_BASE_URL:=${VITE_API_BASE_URL_DEFAULT}}"
: "${VITE_DEPLOY_ENV:=${VITE_DEPLOY_ENV_DEFAULT}}"
: "${VITE_FEATURE_VOICE_INPUT:=${VITE_FEATURE_VOICE_INPUT_DEFAULT}}"
export VITE_API_BASE_URL VITE_DEPLOY_ENV VITE_FEATURE_VOICE_INPUT
${VAR:=default} は「未設定なら default を入れて変数も更新」。${VAR:-default} (デフォルトを返すだけ) とは違うので注意。
API (deploy-api.sh)
3 段構え:
- rsync
api/→~/api.example.com/laravel/(除外:vendor,node_modules,.env*,storage/{logs,framework}/,tests) - ssh で composer install
- ssh で artisan migrate / config:cache / route:cache / view:cache
run_remote() {
local desc="$1"; shift
local cmd="$*"
if [[ ${APPLY} -eq 1 ]]; then
ssh "${XSERVER_SSH_HOST}" "cd ${REMOTE_PATH_FOR_SSH} && ${cmd}"
else
echo " [DRY-RUN] ssh ${XSERVER_SSH_HOST} 'cd ${REMOTE_PATH_FOR_SSH} && ${cmd}'"
fi
}
run_remote "composer install" "/usr/bin/php8.3 ~/bin/composer install --no-dev --optimize-autoloader"
run_remote "php artisan migrate" "/usr/bin/php8.3 artisan migrate --force"
run_remote "php artisan config:cache" "/usr/bin/php8.3 artisan config:cache"
run_remote() は 「DRY-RUN なら echo、APPLY なら実行」を 1 関数に閉じるラッパー。DRY-RUN 出力がそのままチャットに貼って意味が通る、という利点もある。
Composer のシバン罠
Composer (Phar) のデフォルトシバンは #!/usr/bin/env php。これが地味に効いてくる:
- Xserver の
~/bin/phpは PHP 7.4 を指す ~/bin/composer installを素直に叩くと PHP 7.4 で composer が起動する- Laravel 13 は PHP 8.2+ 必須なので
composer installが SyntaxError で失敗
解決は PHP を明示的に呼ぶ:
# ❌ ダメ (7.4 で起動)
~/bin/composer install --no-dev
# ✅ OK (8.3 で起動)
/usr/bin/php8.3 ~/bin/composer install --no-dev --optimize-autoloader
第 4 回でも触れたように、全スクリプトで /usr/bin/php8.3 を明示することで「PHP のバージョンミックス」事故が消える。
apex LP (deploy-apex.sh) と --delete を使わない判断
apex (example.com/public_html/) は、subdomain ディレクトリと 同居している:
~/example.com/public_html/
├── index.html ← apex LP
├── about.html
├── app.example.com/ ← フロント (subdomain doc root)
├── api.example.com ← symlink → laravel/public
├── .user.ini ← Xserver の PHP 設定
└── .htaccess ← Xserver の挙動制御
ここで --delete を使うと subdomain ディレクトリが消え、サイト全体が死ぬ。
なので apex は 「上書きは可、追加も可、削除はしない」rsync にする:
RSYNC_FLAGS=(-avz
--chmod=D755,F644
--exclude '.DS_Store'
--exclude 'app.example.com'
--exclude 'api.example.com'
--exclude '.user.ini'
--exclude '.htaccess'
)
# --delete はあえて付けない
--delete を使わないので「deploy/apex/ から削除したファイルは本番に残り続ける」というトレードオフはある。ただし apex の HTML を消すケースはほぼ無いので許容範囲。
--delete の使い分けマトリクス
rsync 先がスクリプトの専有領域?
├─ YES → --delete を使う
└─ NO → --delete を使わない (除外リストで守る)
これだけのルールで判断できる。「フロント dist/ → app subdomain doc root」は専有なので使う。「apex doc root」は subdomain と同居なので使わない。
mktemp の secure perm 問題
--env dev モードでは「deploy/apex/ の HTML を sed で dev-* URL に書き換えた一時 dir を作って rsync」する。ここで罠:
PREP_TMP=$(mktemp -d -t apex-dev-XXXXXX)
cp -R "${REPO_ROOT}/deploy/apex/." "${PREP_TMP}/"
find "${PREP_TMP}" -type f -name '*.html' -print0 | while IFS= read -r -d '' f; do
sed -i.bak \
-e 's|https://app\.example\.com|https://dev-app.example.com|g' \
-e 's|https://api\.example\.com|https://dev-api.example.com|g' \
"${f}"
rm -f "${f}.bak"
done
rsync -avz "${PREP_TMP}/" "${XSERVER_SSH_HOST}:~/${LP_DOMAIN}/public_html/dev/"
trap 'rm -rf "${PREP_TMP}"' EXIT
macOS の mktemp -d は デフォルト 700 の dir を作る。そのまま rsync で転送すると drwx------ がそのまま反映されて、Apache が dir に入れず 403 を返す。
対処は rsync --chmod=D755,F644:
D755— ディレクトリのパーミッションを755に補正F644— ファイルのパーミッションを644に補正
これで Apache が読める perm になる。
trap 'rm -rf "${PREP_TMP}"' EXIT は どんな終わり方をしても一時 dir を消すためのお決まり。set -e で途中死しても trap は走る。
env で prod / dev を切り替える設計
3 スクリプト共通で --env prod|dev を受け取る:
case "${ENV_TARGET}" in
prod) TARGET_DOMAIN="${APP_DOMAIN}"; REMOTE_SUBDIR="app.example.com" ;;
dev) TARGET_DOMAIN="${APP_DOMAIN_DEV}"; REMOTE_SUBDIR="dev-app" ;;
esac
これで bash deploy-app.sh --env dev --apply が「dev 環境に向けてビルド & 転送」を意味する。
確認プロンプトには 環境名を大文字で出す:
read -r -p "${ENV_TARGET^^} (${TARGET_DOMAIN}) へ転送します。続けますか? [y/N] " ans
# → "PROD (app.example.com) へ転送します。続けますか? [y/N]"
${VAR^^} は Bash 4+ の 大文字化。PROD と表示されることで実行時に手が止まる。AI エージェントに任せる場合も「PROD なら念のため止めたい」の境界を表現できる。
まとめ — 連載全体を振り返って
| 回 | テーマ | 主役 |
|---|---|---|
| 1 | 読み取り系 = curl + jq + column の基本パターン | xs-info.sh |
| 2 | DRY-RUN を既定にする破壊的 API | xs-db-create.sh |
| 3 | DNS TXT 自動投入 + リトライで非同期 API を吸収 | xs-mail-create.sh |
| 4 | idempotent と日次監視 | xs-subdomain-create.sh / xs-cron-set.sh / xs-ssl-check.sh |
| 5 | rsync + SSH で締めくくる | deploy-app.sh / deploy-api.sh / deploy-apex.sh |
連載通じての思想は一つで、「手元の bash + curl + jq + rsync だけで完結する運用」。CI/CD も SaaS も入れずに、Xserver 1 台 + ローカル 1 台で個人開発の運用が完結する。
そしてこの形なら AI エージェントへの委譲も自然に渡せる。DRY-RUN で内容を見せ、--apply で実行、secrets はローカルファイルに分離、idempotent なので再実行も安全 — どれもエージェントが安心して使える性質。「人間の運用パターンを bash で形式化する」と、自然にエージェント向けインタフェースにもなる、というのが筆者の発見だった。



コメント