
踏み台サーバー経由の2ホップデプロイをNode.js(ssh2)で完全自動化してみた
はじめに
閉域ネットワーク上のサーバーにWebアプリをデプロイする必要がありました。ただし、そのサーバーには以下の制約があります。
- インターネット接続なし —
git cloneやdocker pullが使えない - 踏み台サーバー経由でしかSSH接続できない(2ホップ必須)
- 踏み台にはSFTPでアップロード可能だが、踏み台→デプロイ先のSSH鍵は踏み台上にしかない(ローカルから
scp -Jが使えない)
最初は手作業でやっていましたが、コマンドのタイポや手順抜けが頻発したため、パック→転送→展開→起動を1コマンドで完結するスクリプトをNode.jsのssh2ライブラリで実装しました。
この記事では、デプロイオーケストレーター(deploy.mjs)、サーバー側スクリプト(deploy.sh)、リモートログストリーミング(logs.mjs)の3つを紹介します。
前提・環境
| 項目 | 値 |
|---|---|
| ローカルOS | macOS |
| Node.js | 24 LTS |
| ssh2 | 1.17.0 |
| デプロイ先 | Linux(Docker, Docker Compose インストール済み) |
| コンテナ構成 | フロントエンド(Next.js) + バックエンド(FastAPI) |
ネットワーク構成

ローカルからデプロイ先に直接接続する手段はなく、すべて踏み台を経由する必要があります。
全体のデプロイフロー

実装
deploy.mjs — ローカル側オーケストレーター
package.json で "deploy": "node scripts/deploy.mjs" として登録し、pnpm deploy で呼び出します。
SSH認証の解決
まずSSH認証方法を自動解決します。SSH_AUTH_SOCK(ssh-agent)があればそれを使い、なければ ~/.ssh のキーファイルを探します。
function resolveAuth() {
const agentSocket = process.env.SSH_AUTH_SOCK;
if (agentSocket) {
return { agent: agentSocket };
}
for (const name of ['id_ed25519', 'id_rsa', 'id_ecdsa']) {
const keyPath = path.join(os.homedir(), '.ssh', name);
if (fs.existsSync(keyPath)) {
return { privateKey: fs.readFileSync(keyPath) };
}
}
throw new Error('No SSH auth available');
}
Step 1: パック
.git、node_modules、.next、.venv、.env(秘密情報)、.ignore を除外してtar.gzを作成します。
await execFileAsync('tar', [
'-czf', ARCHIVE_PATH,
`--exclude=${PROJECT_NAME}/.git`,
`--exclude=${PROJECT_NAME}/node_modules`,
`--exclude=${PROJECT_NAME}/.next`,
`--exclude=${PROJECT_NAME}/backend/.venv`,
`--exclude=${PROJECT_NAME}/backend/data`,
`--exclude=${PROJECT_NAME}/.env`,
`--exclude=${PROJECT_NAME}/.ignore`,
PROJECT_NAME,
], {
cwd: PARENT_DIR,
env: { ...process.env, COPYFILE_DISABLE: '1' }, // macOSの._ファイル抑止
});
COPYFILE_DISABLE: '1' はmacOS固有で、._ で始まるリソースフォークファイルがアーカイブに混入するのを防ぎます。
Step 2: ローカル → 踏み台(SFTP)
ssh2のsftpサブシステムを使い、進捗表示付きでアップロードします。
function sftpPut(sftp, localPath, remotePath) {
return new Promise((resolve, reject) => {
const total = fs.statSync(localPath).size;
let lastPct = 0;
sftp.fastPut(localPath, remotePath, {
step: (transferred) => {
const pct = Math.floor((transferred / total) * 100);
if (pct >= lastPct + 10) {
process.stdout.write(
`\r ${pct}% (${(transferred / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB)`
);
lastPct = pct;
}
},
}, (err) => {
process.stdout.write('\r 100%\n');
if (err) reject(err); else resolve();
});
});
}
fastPut はファイル全体を効率的に転送するssh2のメソッドで、step コールバックで転送量を監視できます。
Step 3: 踏み台 → デプロイ先(SCP)
踏み台上で scp コマンドを実行します。ローカルから scp -J(ProxyJump)を使えない理由は、SSH鍵が踏み台上にしかないためです。
await bastionExec(
bastion,
`scp -o StrictHostKeyChecking=no ${bastionDest} ${DEPLOY_USER}@${DEPLOY_HOST}:`
);
ここで bastionExec は、踏み台SSHセッション上でコマンドを実行し、stdout/stderrをリアルタイムでローカルに中継するヘルパーです。
function bastionExec(conn, cmd, deployScript) {
return new Promise((resolve, reject) => {
conn.exec(cmd, (err, stream) => {
if (err) return reject(err);
if (deployScript) {
stream.write(deployScript);
stream.end();
}
stream.on('data', (d) => process.stdout.write(d));
stream.stderr.on('data', (d) => process.stderr.write(d));
stream.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`Bastion exec exited with code ${code}: ${cmd}`));
});
});
});
}
Step 4: リモートデプロイ(bashスクリプトをstdinにパイプ)
ここが一番面白いポイントです。deploy.sh をローカルで読み込み、踏み台経由でデプロイ先の bash -s の標準入力にパイプします。
const deployScript = fs.readFileSync(DEPLOY_SH);
const bashCmd = DEPLOY_ARGS ? `bash -s -- ${DEPLOY_ARGS}` : 'bash -s';
await bastionExec(
bastion,
`ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} ${bashCmd}`,
deployScript,
);
bash -s はスクリプトを標準入力から読み取って実行するコマンドです。これにより、デプロイスクリプトをリモートサーバーに事前配置する必要がなく、常にローカルの最新版が実行されます。
デプロイログの自動保存
全出力をタイムスタンプ付きファイルにティーイングしています。
const LOG_DIR = path.resolve(SCRIPT_DIR, '..', '.ignore', 'deploy-logs');
fs.mkdirSync(LOG_DIR, { recursive: true });
const logFile = path.join(LOG_DIR, `${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
const origStdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, ...args) => {
logStream.write(chunk);
return origStdoutWrite(chunk, ...args);
};
process.stdout.write をラップすることで、通常のコンソール出力を維持しつつ、ファイルにも自動保存しています。デプロイ失敗時の原因調査に役立ちます。
deploy.sh — サーバー側スクリプト
deploy.sh は bash -s でリモート実行されるスクリプトです。--init(初回)とフラグなし(更新)で動作を切り替えます。
.envのバックアップと復元
更新時に既存の .env を退避し、新しいソースコード展開後に復元する仕組みです。
# アップデート時: コンテナ停止 → .env退避
if [[ -f "${DEPLOY_DIR}/docker-compose.yml" ]]; then
cd "$DEPLOY_DIR" && docker compose down
cd "$HOME"
if [[ -f "${DEPLOY_DIR}/.env" ]]; then
cp "${DEPLOY_DIR}/.env" "$HOME/.env.my-app.bak"
fi
fi
# 旧ファイル削除 → アーカイブ展開
find "${DEPLOY_DIR}" -mindepth 1 -delete
tar -xzf "$ARCHIVE" --warning=no-unknown-keyword
cp -a "${SOURCE_DIR}/." "$DEPLOY_DIR/"
# .env復元
if [[ -f "$HOME/.env.my-app.bak" ]]; then
cp "$HOME/.env.my-app.bak" "${DEPLOY_DIR}/.env"
fi
.env はアーカイブに含めていない(秘密情報のため)ので、サーバー上で一度設定すれば以降の更新で自動復元されます。
初回デプロイ時の.envガイド
初回(--init)で .env がなければ、設定手順を表示して停止します。
if [[ "$INIT" == true ]]; then
echo "----------------------------------------------"
echo " .env が未設定です。次の手順で作成してください:"
echo ""
echo " cp ${DEPLOY_DIR}/.env.example ${DEPLOY_DIR}/.env"
echo " vi ${DEPLOY_DIR}/.env"
echo ""
echo " 設定後、再度このスクリプトを実行してください:"
echo " pnpm deploy --init"
echo "----------------------------------------------"
exit 1
fi
ヘルスチェック
docker compose up -d --build
sleep 3
curl -sf http://localhost:40002/v1/healthz && echo "backend OK" \
|| echo "WARNING: backend health check failed"
-s(サイレント)-f(HTTPエラー時に失敗)フラグを使い、ヘルスチェック失敗はWARNING表示のみでスクリプト自体は正常終了します。コンテナ起動直後はまだ準備中の場合があるため、3秒待ってからチェックしています。
Docker Compose構成
本番用と開発用のDockerfileを分離し、docker-compose.override.yml でポートをリマップしています。
# docker-compose.yml(ベース)
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
env_file: .env
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
env_file: .env
depends_on:
- backend
restart: unless-stopped
# docker-compose.override.yml(環境固有のポートマッピング)
services:
frontend:
ports:
- "40001:3000"
backend:
ports:
- "40002:8765"
docker-compose.override.yml はDocker Composeが自動的にマージするファイルです。ベースの docker-compose.yml にはポート定義を含めず、環境ごとのオーバーライドで設定することで、同じソースコードを異なるポート体系にデプロイできます。
今回のデプロイ先EC2はコスト削減のため稼働時間が8:00〜22:00にスケジュールされており、毎朝自動起動・毎晩自動停止します。当初は restart ポリシーを設定していなかったため、朝EC2が起動するたびにSSHでログインして手動で docker compose up -d を実行する必要がありました。
restart: unless-stopped を設定することで、Dockerデーモンの起動時(= EC2の起動時)にコンテナが自動的に再開されるようになり、毎朝の手作業が不要になりました。
restart: unless-stopped
always ではなく unless-stopped を選んだのは、docker compose down で意図的に停止した場合はそのまま停止させたいためです。デプロイ中にコンテナを停止→再展開→再起動するフローと干渉しません。
logs.mjs — リモートログストリーミング
デプロイ後のデバッグ用に、リモートの docker compose logs をローカルにストリーミングするスクリプトも作りました。
const FOLLOW = process.argv.includes('--follow');
const TAIL = process.argv.find((a) => a.startsWith('--tail='))?.split('=')[1] ?? '200';
// サービス名のバリデーション(コマンドインジェクション防止)
const rawService = process.argv.find(
(a) => !a.startsWith('-') && a !== process.argv[0] && a !== process.argv[1]
) ?? '';
if (rawService && !/^[a-zA-Z0-9_-]+$/.test(rawService)) {
throw new Error(`Invalid service name: ${rawService}`);
}
const remoteCmd = `ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} \
'cd ${DEPLOY_DIR} && docker compose logs --tail=${TAIL}${followFlag} ${service}'`;
使い方:
pnpm logs # 直近200行を表示
pnpm logs -- --follow # リアルタイムストリーミング
pnpm logs -- --follow backend # backendコンテナのみ
デプロイ先に直接SSHする手間なく、ローカルから1コマンドでログを確認できます。
実行結果
$ pnpm deploy
=== pack ===
Packing project -> ../my-app.tar.gz
created: ../my-app.tar.gz (12.3 MB)
=== ship: step 1 - local -> bastion ===
Bastion connected.
Uploading to bastion: /home/user/my-app.tar.gz
100%
=== ship: step 2 - bastion -> target ===
(~12 MB, may take a minute...)
done: my-app.tar.gz -> user@10.xxx.x.xx:~/
=== remote deploy (bastion -> target) ===
=== extract ===
stopping containers...
.env backed up
extracted → /var/www/my-app
.env restored
=== .env ===
.env OK
=== deploy ===
Building backend...
Building frontend...
...
=== done ===
backend OK
log saved: .ignore/deploy-logs/2026-06-19T10-30-00-000Z.log
パック→2ホップ転送→展開→Docker Compose起動→ヘルスチェックまで、約3分で完了します。
工夫したポイント
| ポイント | 理由 |
|---|---|
bash -s でスクリプトをstdinパイプ |
リモートにスクリプトを配置不要。常にローカル最新版が実行される |
.env バックアップ・復元 |
秘密情報はアーカイブに含めず、更新時も手動再設定不要 |
--init / フラグなしの分岐 |
初回と更新で安全に動作を切り替え |
| SFTP進捗表示 | 大きなアーカイブでもハング判定が容易 |
| デプロイログ自動保存 | 障害時の原因調査が迅速化 |
COPYFILE_DISABLE: '1' |
macOS固有の._ファイル混入防止 |
restart: unless-stopped |
EC2スケジュール起動時にコンテナ自動再開。毎朝の手動起動が不要に |
まとめ
閉域ネットワーク+踏み台経由という制約下でも、ssh2ライブラリを使えばNode.jsスクリプト1本で2ホップのデプロイパイプラインを構築できます。
特に bash -s へのスクリプトパイプは汎用的なテクニックで、リモートサーバーにスクリプトファイルを配置・管理する手間を省けます。Docker Composeの override.yml と組み合わせることで、同一ソースコードを異なる環境ルールにそのまま適用できる柔軟性も得られました。
閉域環境へのデプロイで困っている方の参考になれば幸いです。





