第5回: rsync + SSH で Laravel API とフロントを 1 コマンドでデプロイ (DRY-RUN 付き)

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 の切り替えは --env 1 つで吸収する。VITE_* の build-time env もここで切る。

デプロイ構成の全体像

3 種類のデプロイがある:

ターゲットスクリプト流れ
フロント (Vite PWA)deploy-app.shローカル npm run builddist/ → rsync → app.example.com/
API (Laravel 13)deploy-api.shrsync api/~/api.example.com/laravel/ → ssh で composer + migrate + cache
apex LPdeploy-apex.shrsync 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 段構え:

  1. rsync api/~/api.example.com/laravel/ (除外: vendor, node_modules, .env*, storage/{logs,framework}/, tests)
  2. ssh で composer install
  3. 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
2DRY-RUN を既定にする破壊的 APIxs-db-create.sh
3DNS TXT 自動投入 + リトライで非同期 API を吸収xs-mail-create.sh
4idempotent と日次監視xs-subdomain-create.sh / xs-cron-set.sh / xs-ssl-check.sh
5rsync + 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 で形式化する」と、自然にエージェント向けインタフェースにもなる、というのが筆者の発見だった。

コメント

タイトルとURLをコピーしました