自宅サーバにDockerコンテナをデプロイする

自宅サーバにDockerコンテナをデプロイする

普段の開発では Docker を使っていましたが、本番環境で運用したことがありませんでした。せっかくなので自宅に余っていた小型 PC を使って、GitHub Actions による自動デプロイを構築してみました。

VPS ではなく自宅サーバを選んだ理由は以下の通りです。

  • 小型 PC が何台か遊んでいた
  • 同スペックの VPS と比較して月額料金がそこまで変わらない(消費電力を計測した結果)
  • ルータレベルで VLAN 分離してセキュリティを確保できる

ネットワーク構成やセキュリティ設定については別記事で詳しく書いたので割愛します。

公開した Web アプリケーション

今回デプロイしたのは、GitHub API を使ってリポジトリのトラフィックやフォロワーの推移を記録するアプリケーションです。(※実際のアプリケーション:https://github-traffic.hn-tech-dev.com/

訪問者グラフ

フォロワー詳細

ユーザー管理

主な機能は以下の通りです。

  • リポジトリごとの訪問者数・ユニーク訪問者の日次記録
  • フォロワー/フォローの推移グラフ
  • マルチユーザー対応(各ユーザーが自分の GitHub トークンで管理)

GitHub API の制約上、トラフィックデータは 14 日間しか保持されないため、定期的に取得して DB に保存する必要があります。

環境構成

サーバ環境

  • OS: Ubuntu 24.04.3 LTS
  • Docker: 27.5.1
  • Docker Compose: v2.32.4

開発環境と本番環境で同じ OS・Docker 環境を使用することで、環境差異によるトラブルを減らしました。

ネットワーク構成

外部からのアクセスはNginx Proxy Managerで SSL 終端とリバースプロキシを行い、各コンテナに振り分けています。

Nginx Proxy Managerを使用したネットワークの構築方法は以下の記事で紹介しています。

デプロイフローの全体像

シーケンス図

  1. 開発者がmainブランチに push
  2. GitHub-hosted ランナーが Docker イメージをビルド → GHCR に push
  3. self-hosted ランナーが最新イメージを pull → 本番サーバで起動
  4. ヘルスチェック完了後、マイグレーションとキャッシュ最適化を実行
  5. デプロイ完了

全体で 3〜5 分程度で完了します(イメージサイズとネットワーク速度に依存)。

ダウンタイムについて

現在の実装ではdocker compose downから新コンテナのヘルスチェック完了まで数十秒のダウンタイムが発生します。

個人プロジェクトのため許容していますが、本格運用ではローリングアップデートや Blue-Green デプロイを検討するのが良いと思われます。

ディレクトリ構成

開発環境用の.devcontainerは今回のスコープ外なので省略しています。

github-analytics-laravel/
├── .github/
│   └── workflows/
│       └── deploy-production.yml  # CI/CDワークフロー
├── deploy/
│   ├── docker/
│   │   ├── mysql/
│   │   │   ├── Dockerfile
│   │   │   └── my.cnf
│   │   ├── nginx/
│   │   │   ├── Dockerfile
│   │   │   └── nginx.conf
│   │   └── php/
│   │       ├── Dockerfile         # マルチステージビルド
│   │       ├── php-fpm.conf
│   │       └── docker-entrypoint.sh
│   ├── prod/
│   │   ├── docker-compose.yml
│   │   └── env.template
│   └── scripts/
│       └── deploy.sh              # デプロイ自動化スクリプト
└── src/                           # Laravelアプリケーション本体

Docker イメージのビルド

本番用の PHP-FPM イメージはマルチステージビルドで最適化しました。

"deploy/docker/php/Dockerfile"
# ===========================================
# アセットビルド用ステージ
# ===========================================
FROM node:20-alpine AS assets
WORKDIR /app
COPY src/package*.json ./src/
RUN cd ./src && npm ci --ignore-scripts
COPY src ./src
RUN cd ./src && npm run build

# ===========================================
# PHP拡張ビルド用ステージ
# ===========================================
FROM php:8.2.29-fpm-alpine AS php-builder
# .build-depsという名前でパッケージ群をグループ化(後で一括削除するため)
# 参考:https://qiita.com/pottava/items/970d7b5cda565b995fe7
RUN apk add --no-cache --virtual .build-deps \
      autoconf g++ make linux-headers \
                              # ↓「-dev」はヘッダファイル(コンパイル時のみ必要)
      icu-dev oniguruma-dev libzip-dev \
  && docker-php-ext-install intl pdo_mysql mbstring zip bcmath \
  && pecl install redis \
  && docker-php-ext-enable redis \
                # ↓ 同一RUN内で削除しないとレイヤーに残る(一括削除)
  && apk del .build-deps

# ===========================================
# Composer依存関係インストール用ステージ
# ===========================================
FROM composer:2 AS composer-deps
WORKDIR /app
COPY src/composer.json src/composer.lock ./
# --no-scriptsでpost-install-cmdをスキップ(.envが無い段階では実行できないため)
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs

COPY src .
# 本番環境用に最適化されたオートローダーを生成
RUN composer dump-autoload --optimize --classmap-authoritative --ignore-platform-reqs

# ===========================================
# 本番用ステージ
# ===========================================
FROM php:8.2.29-fpm-alpine AS prod

ENV TZ=Asia/Tokyo \
    APP_ENV=production \
    APP_DEBUG=0

# ランタイム依存のみインストール(ビルドツールは含まない)
RUN apk add --no-cache icu-libs libzip oniguruma tzdata

# ビルド済みPHP拡張をコピー
# extensions/: コンパイル済みの.soファイル(実体)
# conf.d/: extension=redis.soなどの設定ファイル(有効化)
COPY --from=php-builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=php-builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/

WORKDIR /var/www/html

# ビルド済みアプリケーション・依存関係・アセットをコピー
COPY --from=composer-deps /app /var/www/html
COPY --from=assets /app/src/public/build /var/www/html/public/build

# PHP-FPM設定(pm.max_childrenなどをチューニング済み)
COPY deploy/docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/zzz-www.conf

# エントリーポイントスクリプト(storage権限設定やキャッシュクリアを実行)
COPY deploy/docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

# Laravelが必要とするディレクトリを準備
RUN mkdir -p /var/run/php-fpm \
      storage/logs storage/framework/cache \
      storage/framework/sessions storage/framework/views \
      bootstrap/cache \
  && rm -rf bootstrap/cache/*.php \
  && chown -R www-data:www-data /var/www/html /var/run/php-fpm \
  && chmod -R 775 storage bootstrap/cache

# コンテナヘルスチェック
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD pgrep php-fpm > /dev/null || exit 1

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD []

ポイント

マルチステージビルドを採用した理由

  • 最終イメージにビルドツール(gcc、make など)を含めない → イメージサイズ削減
  • Node.js、Composer、PHP 拡張を並列ビルド可能
  • キャッシュレイヤーを最適化しやすい

ハマったポイント

composer install時に--no-scriptsを指定しないと、post-autoload-dumpイベントで 以下のartisanコマンドが実行されます。

"post-autoload-dump": [
    "@php artisan config:clear",
    "@php artisan clear-compiled",
    "@php artisan package:discover --ansi"
]

これらのコマンドは Laravel のブートストラップを必要としますが、このステージでは.envファイルや必要な PHP 拡張(pdo_mysqlなど)がまだ存在しないためエラーになります。--no-scriptsでスキップし、本番ステージで環境が整ってから実行します。

オプション理由
--no-dev本番環境に phpunit 等の開発依存は不要
--no-scripts.envがない段階でartisanコマンドを実行させない
--no-autoloaderソースコピー後に最適化版を生成するため一旦スキップ
--classmap-authoritativePSR-4 の動的解決を無効化し本番パフォーマンス向上
--ignore-platform-reqsこのステージには PHP 拡張がないためプラットフォーム要件を無視

--ignore-platform-reqsについて

composer-depsステージは composer 公式イメージを使用しており、pdo_mysqlintlなどの PHP 拡張がインストールされていません。

このオプションでプラットフォーム要件のチェックをスキップし、パッケージのダウンロードのみを行います。最終的なprodステージにはphp-builderからコピーした拡張が揃っているため、実行時には問題なく動作します。

Nginx と MySQL の Dockerfile についてはリポジトリを参照してください。

GitHub Actions と self-hosted runner の設定

デプロイは self-hosted runner を使用して本番サーバ上で直接実行しています。

なぜ self-hosted runner を選んだか

通常のデプロイでは GitHub Actions のランナーから SSH 接続する必要があり、自宅サーバの SSH ポートを外部公開する必要があります。

self-hosted runner を使えば以下のメリットがあります。

  • インバウンドのポート開放不要 - runner から GitHub へアウトバウンド接続するだけ
  • SSH キー管理不要 - ランナーが本番サーバ上で直接実行
  • ネットワーク構成がシンプル

セキュリティ的に有利なので採用しました。

self-hosted runner のセットアップ

GitHub リポジトリのSettingsActionsRunnersNew self-hosted runnerで表示されるコマンドを本番サーバで実行します。

このコマンドの中に、systemd サービスとして登録するためのスクリプトもダウンロードできます。

self-hosted runnerのセットアップ画面

アーキテクチャは環境に合わせて選択します(今回は x64)。

初回はテストのため./run.shを手動実行し、問題なければ systemd サービスとして登録しました。

# systemdサービス化(例)
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status

これでシステム起動時に自動起動されます。

ワークフローファイルの構成

.github/workflows/deploy-production.ymlは 2 つのジョブで構成しています。

1. build-and-push ジョブ(GitHub-hosted ランナー)

Docker イメージをビルドして、GitHub Container Registry (GHCR) に push します。

".github/workflows/deploy-production.yml"
env:
  REGISTRY: ghcr.io

jobs:
  build-and-push:
    name: Build and Push Docker Images
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    outputs:
      image_name: ${{ steps.meta.outputs.image_name }}
      timestamp: ${{ steps.meta.outputs.timestamp }}
      sha_short: ${{ steps.meta.outputs.sha_short }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Generate metadata
        id: meta
        run: |
          IMAGE_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
          TIMESTAMP=$(date +%Y%m%d-%H%M%S)
          SHA_SHORT=$(echo "${{ github.sha }}" | cut -c1-7)

          echo "image_name=${IMAGE_NAME}" >> $GITHUB_OUTPUT
          echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
          echo "sha_short=${SHA_SHORT}" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push App image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./deploy/docker/php/Dockerfile
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/app:latest
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/app:${{ steps.meta.outputs.timestamp }}
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/app:${{ steps.meta.outputs.sha_short }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          target: prod

      - name: Build and push Web image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./deploy/docker/nginx/Dockerfile
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/web:latest
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/web:${{ steps.meta.outputs.timestamp }}
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/web:${{ steps.meta.outputs.sha_short }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Build and push DB image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./deploy/docker/mysql/Dockerfile
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/db:latest
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/db:${{ steps.meta.outputs.timestamp }}
            ${{ env.REGISTRY }}/${{ steps.meta.outputs.image_name }}/db:${{ steps.meta.outputs.sha_short }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

イメージタグ戦略

イメージは 3 種類のタグで push しています。

  • latest: 常に最新版を指す(通常はこれを使用)
  • 20251230-143022: デプロイ時刻を記録(タイムスタンプ)
  • a1b2c3d: コミット SHA の短縮版

これにより、問題が発生した際に過去の特定バージョンへ簡単にロールバックできます。

キャッシュ戦略

cache-from: type=ghacache-to: type=gha,mode=maxで GitHub Actions のキャッシュを使用しています。2 回目以降のビルドが大幅に高速化されます(初回 15 分 → 2 回目以降 3 分程度)。

2. deploy ジョブ(self-hosted ランナー)

本番サーバで実行され、実際のデプロイを行います。

".github/workflows/deploy-production.yml"
deploy:
  name: Deploy to Production Server
  needs: build-and-push
  runs-on: self-hosted
  # mainブランチのみデプロイを実行
  if: github.ref == 'refs/heads/main'
  env:
    GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
    GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}

  steps:
    - name: Checkout deployment scripts
      uses: actions/checkout@v4
      with:
        sparse-checkout: |
          deploy/scripts
          deploy/prod

    - name: Setup production .env file
      env:
        PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
      run: |
        DEPLOY_DIR="/home/$(whoami)/deploy/github-analytics"
        mkdir -p "$DEPLOY_DIR"

        # .envファイルをbase64デコードして配置
        if [ -n "$PRODUCTION_ENV" ]; then
          echo "$PRODUCTION_ENV" | base64 --decode > "$DEPLOY_DIR/.env"
          echo "✅ .env file created from secrets"
        else
          echo "⚠️ PRODUCTION_ENV secret not found"
          if [ ! -f "$DEPLOY_DIR/.env" ]; then
            echo "❌ .env file does not exist"
            exit 1
          fi
          echo "ℹ️ Using existing .env file"
        fi

    - name: Deploy application
      run: |
        cd deploy/scripts
        chmod +x deploy.sh
        ./deploy.sh

    - name: Deployment summary
      run: |
        echo "## ✅ Deployment Completed" >> $GITHUB_STEP_SUMMARY
        echo "" >> $GITHUB_STEP_SUMMARY
        echo "**Timestamp:** $(date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_STEP_SUMMARY
        echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY

sparse-checkout を使う理由

全リポジトリを clone すると時間がかかるため、デプロイに必要なdeploy/scriptsdeploy/prodのみチェックアウトしています。デプロイ時間を短縮できます。

環境変数ファイルの扱い

.envは Git に含めないため、GitHub Secrets に保存した内容を base64 デコードして配置します。

また、GitHub Secrets は改行を含む値を正しく扱えない場合があるため、base64 エンコードで 1 行の文字列として保存しています。

# Secretsに登録する際
cat .env | base64 -w 0

登録方法などは以下の記事で解説していますので参考にしてください。

必要な GitHub Secrets

以下をリポジトリの Secrets に設定します。

Secret 名説明
GHCR_TOKENPersonal Access Token(read:packages権限)
GHCR_USERNAMEGitHub ユーザー名
PRODUCTION_ENV.envファイルの内容(base64 エンコード済み)

GHCR_TOKENは、GitHub Container Registry から非公開イメージを pull するために必要です。

また、プロフィールのSettingsDeveloper SettingsPersonal access tokens (classic) で以下の項目に対する許可を追加しトークンを取得します。

取得したトークンはGHCR_TOKENの Secrets に設定してください。

Edit personal access token (classic)

ワークフロートリガー

  • mainブランチへの push: ビルド + デプロイ実行
  • developブランチへの push: ビルドのみ実行
  • 手動実行: GitHub Actions UI からworkflow_dispatchで実行可能

これにより、開発ブランチでイメージビルドをテストし main マージ時に自動デプロイできます。

docker-compose.yml の構成

本番環境用のdocker-compose.ymlは 6 つのサービスで構成しています。

"deploy/prod/docker-compose.yml"
volumes:
  db-store:
    name: github-analytics_db
  php-fpm-socket:
    name: github-analytics_php-fpm-socket
  app-storage:
    name: github-analytics_app-storage
  app-public:
    name: github-analytics_app-public
  redis-data:
    name: github-analytics_redis

networks:
  nginx-proxy-manager-network:
    external: true

services:
  app:
    container_name: github-analytics-backend
    image: ${REGISTRY_URL}/app:${IMAGE_TAG:-latest}
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      TZ: Asia/Tokyo
    env_file:
      - .env
    volumes:
      - php-fpm-socket:/var/run/php-fpm
      - app-storage:/var/www/html/storage
      - app-public:/var/www/html/public
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php -v >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - nginx-proxy-manager-network

  web:
    container_name: github-analytics-web
    image: ${REGISTRY_URL}/web:${IMAGE_TAG:-latest}
    depends_on:
      - app
    expose:
      - "80"
    volumes:
      - php-fpm-socket:/var/run/php-fpm
      - app-public:/var/www/html/public:ro
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
    networks:
      - nginx-proxy-manager-network

  db:
    container_name: github-analytics-db
    image: ${REGISTRY_URL}/db:${IMAGE_TAG:-latest}
    volumes:
      - db-store:/var/lib/mysql
    env_file:
      - .env
    environment:
      TZ: ${TIME_ZONE}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
      interval: 30s
      timeout: 5s
      retries: 5
    networks:
      - nginx-proxy-manager-network

  redis:
    container_name: github-analytics-redis
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:-}"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 3s
      retries: 3
    networks:
      - nginx-proxy-manager-network

  # Laravel Scheduler
  scheduler:
    container_name: github-analytics-scheduler
    image: ${REGISTRY_URL}/app:${IMAGE_TAG:-latest}
    user: "www-data:www-data"
    entrypoint: []
    command: php artisan schedule:work --verbose
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      TZ: Asia/Tokyo
    env_file:
      - .env
    volumes:
      - app-storage:/var/www/html/storage
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - nginx-proxy-manager-network

  # phpMyAdmin(データベース管理用)
  # ローカルホストからのみアクセス可能
  phpmyadmin:
    image: phpmyadmin:latest
    container_name: github-analytics-phpmyadmin
    ports:
      - "8091:80"
    environment:
      PMA_ARBITRARY: 1
      PMA_HOST: db
      PMA_PORT: 3306
      PMA_USER: ${MYSQL_USER}
      PMA_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - nginx-proxy-manager-network

scheduler コンテナの起動方法

scheduler コンテナはappと同じイメージを使用しますが、起動方法が異なります。

scheduler:
  image: ${REGISTRY_URL}/app:${IMAGE_TAG:-latest}
  entrypoint: [] # Dockerfileで定義したENTRYPOINTを無効化
  command: php artisan schedule:work --verbose # 直接コマンドを実行

entrypoint: []で Dockerfile のENTRYPOINT(docker-entrypoint.sh)を無効化し、commandで直接php artisan schedule:workを実行しています。

これにより、同じイメージから異なるプロセスを起動できます。

アーキテクチャの特徴

1 コンテナ 1 プロセス原則

  • app: PHP-FPM(Web アプリケーション本体)
  • scheduler: Laravel Scheduler(schedule:work
  • web: Nginx(リバースプロキシ)
  • db: MySQL
  • redis: Redis(キャッシュ・セッション・キュー)
  • phpmyadmin: phpMyAdmin(データベース管理用、ローカルアクセスのみ)

scheduler を別コンテナに分離することで、アプリケーションコンテナの再起動時にスケジューラーが停止しません。

ヘルスチェック依存関係

depends_oncondition: service_healthyを指定することで、DB が完全に起動してからアプリケーションを起動できます。

ネットワーク分離

すべてのコンテナはnginx-proxy-manager-networkという外部ネットワークに接続しています。このネットワークは Nginx Proxy Manager と共有しており、リバースプロキシ経由で外部からのアクセスを受け付けます。

デプロイスクリプトの詳細

deploy/scripts/deploy.shがデプロイの実処理を行います。

"deploy/scripts/deploy.sh"
#!/bin/bash

###############################################################################
# GitHub Analytics - Production Deployment Script
###############################################################################
# このスクリプトは本番環境へのデプロイを自動化します。
# 主な処理内容:
# 1. デプロイディレクトリの準備とdocker-compose.ymlのコピー
# 2. GitHub Container Registryへのログイン
# 3. 最新のDockerイメージの取得
# 4. 既存コンテナの停止と新規コンテナの起動
# 5. サービスヘルスチェック待機
# 6. データベース接続確認とマイグレーション実行
# 7. 管理者ユーザーの作成(シーダー実行)
# 8. Livewireアセットの公開
# 9. Laravelキャッシュの最適化
# 10. 古いDockerイメージのクリーンアップ

set -e  # エラーで停止

# カラー出力用の定義
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# ログ出力関数
log_info() {
    echo -e "${BLUE}[INFO]${NC} $1"
}

log_success() {
    echo -e "${GREEN}[SUCCESS]${NC} $1"
}

log_warning() {
    echo -e "${YELLOW}[WARNING]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

# ============================================================================
# 1. デプロイディレクトリの準備
# ============================================================================
# デプロイディレクトリへ移動
DEPLOY_DIR="/home/$(whoami)/deploy/github-analytics"
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml"

log_info "Deployment started at $(date '+%Y-%m-%d %H:%M:%S')"

# デプロイディレクトリが存在しない場合は作成
if [ ! -d "$DEPLOY_DIR" ]; then
    log_warning "Deploy directory does not exist. Creating: $DEPLOY_DIR"
    mkdir -p "$DEPLOY_DIR"
fi

# docker-compose.ymlをコピー
log_info "Copying docker-compose.yml to $DEPLOY_DIR"
cp -f ../prod/docker-compose.yml "$DEPLOY_DIR/"

# .envファイルの存在確認(初回デプロイ時の警告)
if [ ! -f "$DEPLOY_DIR/.env" ]; then
    log_error ".env file not found in $DEPLOY_DIR"
    log_error "Please create .env file based on env.template before deployment"
    log_error "Run: cp ../prod/env.template $DEPLOY_DIR/.env"
    exit 1
fi

# 現在のディレクトリを変更
cd "$DEPLOY_DIR"

# ============================================================================
# 2. GitHub Container Registryへのログイン
# ============================================================================
# GitHub Container Registryにログイン
log_info "Logging in to GitHub Container Registry..."
if [ -n "$GHCR_TOKEN" ]; then
    echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
    log_success "Logged in to ghcr.io"
else
    log_warning "GHCR_TOKEN not set. Attempting to use cached credentials..."
fi

# ============================================================================
# 3. 最新のDockerイメージの取得
# ============================================================================
# 最新のイメージをPull
log_info "Pulling latest Docker images from registry..."
docker compose pull

# ============================================================================
# 4. 既存コンテナの停止と新規コンテナの起動
# ============================================================================
# コンテナの停止と削除(データベースボリュームは保持)
log_info "Stopping existing containers..."
docker compose down --remove-orphans

# コンテナの起動
log_info "Starting containers..."
docker compose up -d

# ============================================================================
# 5. サービスヘルスチェック待機
# ============================================================================
# ヘルスチェック待機
log_info "Waiting for services to be healthy..."
MAX_WAIT=60
WAIT_COUNT=0
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
    DB_HEALTHY=$(docker compose ps db --format json | grep -o '"Health":"[^"]*"' | cut -d'"' -f4)
    APP_HEALTHY=$(docker compose ps app --format json | grep -o '"Health":"[^"]*"' | cut -d'"' -f4)

    if [ "$DB_HEALTHY" = "healthy" ] && [ "$APP_HEALTHY" = "healthy" ]; then
        log_success "All services are healthy!"
        break
    fi

    WAIT_COUNT=$((WAIT_COUNT + 1))
    log_info "Waiting for services... ($WAIT_COUNT/$MAX_WAIT) - DB: $DB_HEALTHY, App: $APP_HEALTHY"
    sleep 2
done

if [ $WAIT_COUNT -eq $MAX_WAIT ]; then
    log_error "Services did not become healthy in time!"
    docker compose ps
    docker compose logs --tail=50 app
    docker compose logs --tail=50 db
    exit 1
fi

# 設定キャッシュをクリア(実行時の環境変数を使用するため)
log_info "Clearing configuration cache..."
docker compose exec -T app php artisan config:clear || true

# ============================================================================
# 6. データベース接続確認
# ============================================================================
# データベース接続確認(追加の安全チェック)
# 注意: 接続確認が失敗しても、マイグレーション処理は実行されます
log_info "Verifying database connection..."
MAX_DB_RETRIES=15
DB_RETRY_COUNT=0
DB_CONNECTION_OK=false

while [ $DB_RETRY_COUNT -lt $MAX_DB_RETRIES ]; do
    DB_RETRY_COUNT=$((DB_RETRY_COUNT + 1))

    # 最後のリトライではエラー内容を表示
    if [ $DB_RETRY_COUNT -eq $MAX_DB_RETRIES ]; then
        log_info "Final connection attempt with detailed output..."
        if docker compose exec -T app php artisan db:show; then
            log_success "Database connection verified!"
            DB_CONNECTION_OK=true
            break
        else
            log_warning "Could not establish database connection at this stage!"
            log_info "This may be normal if MySQL is still initializing."
            log_info "Will attempt to run migrations anyway..."
            log_info "Checking environment variables..."
            docker compose exec -T app env | grep -E "(DB_|MYSQL_)" | grep -v PASSWORD
            # 接続確認が失敗しても続行(exit 1を削除)
            break
        fi
    else
        if docker compose exec -T app php artisan db:show > /dev/null 2>&1; then
            log_success "Database connection verified!"
            DB_CONNECTION_OK=true
            break
        fi
        log_info "Database connection check... retry $DB_RETRY_COUNT/$MAX_DB_RETRIES"
        sleep 3
    fi
done

if [ "$DB_CONNECTION_OK" = false ]; then
    log_warning "Database connection verification failed, but continuing with migration attempt..."
fi

# ============================================================================
# 7. データベースマイグレーション実行
# ============================================================================
# データベースマイグレーション実行(接続確認が失敗した場合でも試行)
log_info "Running database migrations..."
MAX_MIGRATION_RETRIES=10
MIGRATION_RETRY_COUNT=0
MIGRATION_SUCCESS=false

while [ $MIGRATION_RETRY_COUNT -lt $MAX_MIGRATION_RETRIES ]; do
    MIGRATION_RETRY_COUNT=$((MIGRATION_RETRY_COUNT + 1))

    if docker compose exec -T app php artisan migrate --force; then
        log_success "Database migrations completed successfully"
        MIGRATION_SUCCESS=true
        break
    else
        if [ $MIGRATION_RETRY_COUNT -lt $MAX_MIGRATION_RETRIES ]; then
            log_warning "Migration attempt $MIGRATION_RETRY_COUNT/$MAX_MIGRATION_RETRIES failed. Retrying..."
            sleep 5
        else
            log_error "Database migrations failed after $MAX_MIGRATION_RETRIES attempts!"
            log_info "Checking app container logs..."
            docker compose logs --tail=50 app
            log_info "Checking db container logs..."
            docker compose logs --tail=30 db
            exit 1
        fi
    fi
done

# ============================================================================
# 8. 管理者ユーザーの作成(シーダー実行)
# ============================================================================
# 管理者ユーザーの作成(初回デプロイ時 or 未作成時)
log_info "Running AdminUserSeeder..."
if docker compose exec -T app php artisan db:seed --class=AdminUserSeeder --force; then
    log_success "AdminUserSeeder completed successfully"
else
    log_error "AdminUserSeeder failed!"
    log_info "Checking app container logs..."
    docker compose logs --tail=50 app
    exit 1
fi

# ============================================================================
# 9. Livewireアセットの公開
# ============================================================================
# Livewireの静的アセットを公開(JavaScriptファイルなど)
log_info "Publishing Livewire assets..."
if docker compose exec -T app php artisan livewire:publish --force; then
    log_success "Livewire assets published successfully"
else
    log_warning "Failed to publish Livewire assets, but continuing..."
fi

# ============================================================================
# 10. Laravelキャッシュの最適化
# ============================================================================
# キャッシュクリア&最適化
log_info "Clearing and optimizing caches..."
docker compose exec -T app php artisan config:cache
docker compose exec -T app php artisan route:cache
docker compose exec -T app php artisan view:cache
docker compose exec -T app php artisan optimize

# コンテナステータス確認
log_info "Checking container status..."
docker compose ps

# ログ確認(最後の20行)
log_info "Recent logs:"
docker compose logs --tail=20

log_success "Deployment completed successfully at $(date '+%Y-%m-%d %H:%M:%S')"
log_info "Application is running at the configured domain"

# ============================================================================
# 11. 古いDockerイメージのクリーンアップ
# ============================================================================
# クリーンアップ(古いイメージの削除)
log_info "Cleaning up old Docker images..."
docker image prune -f

log_success "All done! 🚀"

docker compose exec -Tのコマンドですが、GitHub Actions のような non-interactive 環境では TTY が割り当てられないため、-T で擬似 TTY を無効化する必要があります。これを省略するとthe input device is not a TTYエラーになります。

スクリプトのポイント

エラーハンドリング

set -eでエラー時に即座に停止します。

ヘルスチェック待機

コンテナ起動後、すぐにマイグレーションを実行すると DB の準備が整っていない場合があります。ヘルスチェックが通るまで最大 60 秒待機します。

キャッシュ最適化

本番環境ではconfig:cacheroute:cacheview:cacheで設定・ルート・ビューをキャッシュし、パフォーマンスを向上させます。

イメージクリーンアップ

docker image prune -fで dangling images(タグなし中間イメージ)のみ削除します。過去のバージョンは残しておき、ロールバック可能にしています。

運用上の Tips

ロールバック方法

問題が発生した場合、過去のイメージタグを指定してロールバックできます。

# docker-compose.ymlのイメージタグを変更
# 例: latest → 20251229-150000
services:
  app:
    image: ghcr.io/${GITHUB_REPOSITORY}/app:20251229-150000

# コンテナ再起動
docker compose up -d

ログ確認

# 全サービスのログ
docker compose logs -f

# 特定サービスのログ
docker compose logs -f app
docker compose logs -f scheduler

データベースバックアップ

スケジューラーで毎日自動実行されますが、手動実行も可能です。

# Gzip圧縮バックアップ
docker compose exec app php artisan db:backup --gzip

# SQL形式バックアップ
docker compose exec app php artisan db:backup

モニタリング

Nginx Proxy Manager のアクセスログと、Laravel のログを定期的に確認しています。

# Laravelログ
docker compose exec app tail -f storage/logs/laravel.log

# GitHubコマンドログ
docker compose exec app tail -f storage/logs/github-commands.log

まとめ

自宅サーバでの Docker 運用は、VPS と比べて以下のメリットがありました。

  • コスト: 消費電力を考慮しても VPS より安い
  • スペック: 同価格帯でより高スペック
  • 学習: インフラ全体を触れるので勉強になる

一方、以下のデメリットもあります。

  • 停電リスク: UPS がないと落ちる(導入を検討中)
  • ネットワーク障害: 回線トラブル時に復旧できない
  • 物理メンテ: ハードウェア故障時は自分で対応

今回構築した CI/CD パイプラインは、self-hosted runner を使うことで SSH ポート公開不要というセキュリティメリットが大きかったです。同様の構成を検討している方の参考になれば嬉しいです。

プライベートリポジトリでの運用について

実際の運用では、self-hosted runner を使用しているためリポジトリをプライベートで管理しています。

GitHub 公式ドキュメントでは、パブリックリポジトリで self-hosted runner を使用しないことが推奨されています。パブリックリポジトリでは誰でもフォークして Pull Request を作成できるため、悪意のあるワークフローが差し込まれると、runner がインストールされているサーバー上で任意のコードが実行されるリスクがあります。

プライベートリポジトリで開発・運用を行い、公開用のリポジトリ(github-analytics-public)にコードをコピーするワークフローを GitHub Actions で自動化しています。これにより、セキュリティを確保しつつコードを公開できます。

公開用リポジトリへの同期方法については、後日別記事で詳しく紹介する予定です。

参考リンク