公開日 2026-06-05

GitHub Actionsで docker compose を使うPHP CI(PostgreSQL付き)

ローカルの Docker Compose 構成をそのまま GitHub Actions に載せ、PostgreSQL 付き PHPUnit を CI で再現できるようにする。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. ローカル用の最小 DB テスト構成をそろえる
  4. 3. ローカルで DB テストを通して前提を固定する
  5. コードのポイント
  6. 4. .github/workflows/ci.yml を作る
  7. 5. PostgreSQL の起動待ちと docker compose exec -T を押さえる
  8. 6. 失敗ログの見方と if: failure() のログ採取
  9. 7. よくある詰まりどころを切り分ける
  10. 8. まとめと次の一歩

この 1 本で、ローカルの app + db 構成を GitHub Actions に載せ、PostgreSQL 付き PHPUnit を docker compose exec -T で回すところまで進めます。 扱うのは compose.yml 共用、起動待ち、-T、失敗時ログです。services 方式との詳細比較、matrix、PHPStan / PHP CS Fixer 追加は入れません。

補助導線として先に読んでおくと入りやすい記事:

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • GitHub.com 上に作成済みのリポジトリ(origin 設定済み)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。 サービス名は appdb、テスト用 DB 名は app_test に固定します。

1. ゴールと非対象

到達する状態:

  • ローカルの compose.yml をそのまま使って PostgreSQL 付き PHPUnit を回せる
  • GitHub Actions で docker compose up -d --build -> docker compose exec -T app composer test を再現できる
  • CI が落ちたときに、Actions の step と docker compose logs のどちらを見るべきか判断できる

扱わない内容:

  • GitHub Actions の services 方式を主線にした構成
  • PHPStan / PHP CS Fixer まで含めた品質ゲート
  • 複数 PHP バージョン matrix
  • cache 最適化や image push
  • Laravel / Symfony 固有の CI 設定

前記事の GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI は、runner 上の PHP だけで十分なケース向けでした。 今回は DB を含むので、ローカルの実行環境と CI の差を減らすために app コンテナへ寄せます。

2. ローカル用の最小 DB テスト構成をそろえる

記事用の最小デモを作ります。

mkdir -p ~/projects/php-github-actions-docker-compose-ci-demo
cd ~/projects/php-github-actions-docker-compose-ci-demo
git init
git branch -M main
mkdir -p docker/php docker/db/init src tests
code .

compose.yml を作成します。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    env_file:
      - .env
    command: ["sleep", "infinity"]
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./docker/db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 3s
      timeout: 3s
      retries: 20

volumes:
  db-data:

docker/php/Dockerfile を作成します。 php:8.5-cli イメージの存在確認は docker manifest inspect php:8.5-cli で行えます。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libpq-dev \
    && docker-php-ext-install pdo pdo_pgsql \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

.env.example を作成します。

POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app

APP_DB_HOST=db
APP_DB_PORT=5432
APP_DB_NAME=app
APP_DB_USER=app
APP_DB_PASS=app

docker/db/init/01-create-test-db.sql を作成します。

CREATE DATABASE app_test;

docker/db/init/02-schema.sql を作成します。

CREATE TABLE IF NOT EXISTS tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  is_done BOOLEAN NOT NULL DEFAULT FALSE
);

\connect app_test

CREATE TABLE IF NOT EXISTS tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  is_done BOOLEAN NOT NULL DEFAULT FALSE
);

最初に既定の app にテーブルを作り、そのあと \connect app_test でテスト用 DB に同じスキーマを作る形です。 この切り替えを入れておくと、ローカルでも CI でも appapp_test を同じ初期化 SQL でそろえられます。

composer.json は最終形を先に置きます。

{
  "name": "example/php-github-actions-docker-compose-ci-demo",
  "description": "Minimal Docker Compose CI demo for GitHub Actions article",
  "type": "project",
  "license": "MIT",
  "require": {
    "php": "^8.5",
    "ext-pdo": "*",
    "ext-pdo_pgsql": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^13"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit"
  }
}

.env を作って起動します。

cp .env.example .env
docker compose up -d --build
docker compose exec db pg_isready -U app -d app
docker compose exec db psql -U app -d app_test -c "select 1"
docker compose exec db psql -U app -lqt
docker compose exec db psql -U app -d app -c "\dt"
docker compose exec db psql -U app -d app_test -c "\dt"

確認ポイント:

  • appapp_test が見える
  • appapp_test の両方に tasks テーブルがある
  • pg_isready はサーバー受付確認、psql -d app_test -c "select 1" はテスト用 DB への接続確認になっている
DBの起動確認とテーブル検証コマンドの実行結果

詰まったとき:

docker compose logs db
docker compose down -v
docker compose up -d --build

docker-entrypoint-initdb.d の SQL は初回起動時だけ走ります。 01-create-test-db.sql02-schema.sql を直したのに反映されないときは、down -v まで戻ります。

3. ローカルで DB テストを通して前提を固定する

次に、ローカルで通る composer test を先に作ります。 CI が落ちたときも、最初に戻る場所はここです。

phpunit.xml を作成します。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
  <testsuites>
    <testsuite name="default">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
  <php>
    <env name="APP_DB_NAME" value="app_test" force="true"/>
  </php>
</phpunit>

force="true" により、.env 側の APP_DB_NAME=app をテスト時だけ app_test へ上書きします。 これが無いと、PHPUnit 実行時も通常用 DB を向いたままになります。

src/TaskRepository.php を作成します。

DB への問い合わせを担うリポジトリ。findIncompleteTitles() が未完了タスクのタイトルを list<string> で返す。

<?php

declare(strict_types=1);

namespace App;

use PDO;

final class TaskRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /**
     * @return list<string>
     */
    public function findIncompleteTitles(): array
    {
        $stmt = $this->pdo->query(
            'SELECT title FROM tasks WHERE is_done = FALSE ORDER BY id ASC'
        );

        return array_map(
            static fn (array $row): string => (string) $row['title'],
            $stmt->fetchAll()
        );
    }
}

tests/DatabaseTestCase.php を作成します。

DB テスト共通の基底クラス。setUp() で実 DB に接続し、TRUNCATE でテーブルをリセットしてから各テストを始める。

<?php

declare(strict_types=1);

namespace Tests;

use PDO;
use PHPUnit\Framework\TestCase;

abstract class DatabaseTestCase extends TestCase
{
    protected PDO $pdo;

    protected function setUp(): void
    {
        parent::setUp();

        $dsn = sprintf(
            'pgsql:host=%s;port=%s;dbname=%s',
            getenv('APP_DB_HOST') ?: 'db',
            getenv('APP_DB_PORT') ?: '5432',
            getenv('APP_DB_NAME') ?: 'app_test'
        );

        $this->pdo = new PDO(
            $dsn,
            getenv('APP_DB_USER') ?: 'app',
            getenv('APP_DB_PASS') ?: 'app',
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]
        );

        $this->pdo->exec('TRUNCATE TABLE tasks RESTART IDENTITY');
    }

    /**
     * @param list<array{title: string, is_done: bool}> $tasks
     */
    protected function seedTasks(array $tasks): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO tasks (title, is_done) VALUES (:title, :is_done)'
        );

        foreach ($tasks as $task) {
            $stmt->bindValue(':title', $task['title'], PDO::PARAM_STR);
            $stmt->bindValue(':is_done', $task['is_done'], PDO::PARAM_BOOL);
            $stmt->execute();
        }
    }
}

tests/TaskRepositoryTest.php を作成します。

<?php

declare(strict_types=1);

namespace Tests;

use App\TaskRepository;

final class TaskRepositoryTest extends DatabaseTestCase
{
    private TaskRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seedTasks([
            ['title' => '買い物', 'is_done' => false],
            ['title' => '請求書を送る', 'is_done' => true],
            ['title' => 'ブログを書く', 'is_done' => false],
        ]);

        $this->repository = new TaskRepository($this->pdo);
    }

    public function testFindIncompleteTitlesReturnsOnlyTodoTasks(): void
    {
        $titles = $this->repository->findIncompleteTitles();

        $this->assertSame(['買い物', 'ブログを書く'], $titles);
    }

    public function testFindIncompleteTitlesStillReturnsTwoItems(): void
    {
        $titles = $this->repository->findIncompleteTitles();

        $this->assertCount(2, $titles);
    }
}

依存を入れて、ローカルで確認します。

docker compose exec app composer install --no-interaction --no-progress
docker compose exec app composer validate --strict
docker compose exec app composer test

phpunit/phpunit のバージョン一覧は docker run --rm composer:2 show phpunit/phpunit --all で確認できます。 composer install を先に 1 回通しておくと composer.lock も生成され、以降の CI がぶれにくくなります。

composer.lock は必ずコミットしてください。 GitHub Actions 側の composer validate --strictcomposer install --prefer-dist は、この lock を前提に再現します。

ローカルでcomposer validate --strictとcomposer testが成功しているターミナル出力

composer install 直後、VS Code の Intelephense が PHPUnit\Framework\TestCase を未解決として赤く表示することがあります。 コマンドパレット(Ctrl+Shift+P)から「PHP Intelephense: Index Workspace」を実行するとインデックスが更新されます。

コンテナ内で git コマンドを実行したときに /workspace のオーナーシップ警告が出る場合は、以下で抑制できます。

docker compose exec app git config --global --add safe.directory /workspace

詰まったとき:

  • could not find driver: docker/php/Dockerfilepdo_pgsqldocker compose up -d --build を確認する
  • relation "tasks" does not exist: 2章へ戻り、app_test 側の tasks テーブルを確認する
  • APP_DB_NAME が切り替わらない: phpunit.xmlforce="true" を確認する

コードのポイント

getenv() ?: で環境変数から DSN を組み立てる

tests/DatabaseTestCase.phpsetUp() のポイントはここ。

$dsn = sprintf(
    'pgsql:host=%s;port=%s;dbname=%s',
    getenv('APP_DB_HOST') ?: 'db',
    getenv('APP_DB_PORT') ?: '5432',
    getenv('APP_DB_NAME') ?: 'app_test'
);

getenv() が空のときに ?: でデフォルト値を補う形にしている。 .envphpunit.xmlforce="true" の切り替えが効いていれば、APP_DB_NAMEapp_test になる。

TRUNCATE TABLE tasks RESTART IDENTITY でテストを毎回リセットする

tests/DatabaseTestCase.phpsetUp() 末尾のポイントはここ。

$this->pdo->exec('TRUNCATE TABLE tasks RESTART IDENTITY');

setUp() で呼ぶことで、テストメソッドが始まるたびにテーブルをゼロに戻す。 RESTART IDENTITY は BIGSERIAL のシーケンスも初期値に戻すため、ID の並びがテスト間でぶれない。

array_map + 型付きクロージャで行を文字列に変換する

src/TaskRepository.phpfindIncompleteTitles() のポイントはここ。

return array_map(
    static fn (array $row): string => (string) $row['title'],
    $stmt->fetchAll()
);

fetchAll() が返す連想配列の配列を、クロージャで string 型に絞りながら変換する。 戻り値型 : string(string) キャストを両方付けることで、PHPStan の静的解析が通りやすくなる。

4. .github/workflows/ci.yml を作る

ここから GitHub Actions 側です。 ローカルの compose.yml をそのまま使い、PHP 実行は runner ではなく app コンテナ内へ寄せます。

まずディレクトリを作成します。

mkdir -p .github/workflows

.github/workflows/ci.yml を作成します。

name: ci

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  db-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Prepare env file
        run: cp .env.example .env

      - name: Show Docker Compose version
        run: docker compose version

      - name: Build and start containers
        run: docker compose up -d --build

      - name: Show container status
        run: docker compose ps

      - name: Wait for PostgreSQL server
        run: timeout 120 sh -c 'until docker compose exec -T db pg_isready -U app -d app; do sleep 1; done'

      - name: Wait for test database
        run: timeout 120 sh -c 'until docker compose exec -T db psql -U app -d app_test -c "select 1" > /dev/null; do sleep 1; done'

      - name: Validate composer.json
        run: docker compose exec -T app composer validate --strict

      - name: Install dependencies
        run: docker compose exec -T app composer install --no-interaction --no-progress --prefer-dist

      - name: Run PHPUnit
        run: docker compose exec -T app composer test

      - name: Show container status on failure
        if: failure()
        run: docker compose ps

      - name: Show db logs on failure
        if: failure()
        run: docker compose logs db

      - name: Show app logs on failure
        if: failure()
        run: docker compose logs app

      - name: Shut down containers
        if: always()
        run: docker compose down -v

この workflow で押さえたい点:

  • actions/checkout@v6 でコードを取得してから docker compose を使う
  • .env は Secrets ではなく cp .env.example .env で最小デモを再現する
  • docker compose up -d --buildappdb を起動する
  • composer validate --strict / composer install / composer test はすべて app コンテナ内で実行する
  • 失敗時に pslogs を残し、最後は always() で片付ける

今回の主題はローカルと同じ app コンテナでテストを回すことなので、setup-php は使いません。 実行環境を 1 か所に寄せると、DB 拡張や OS パッケージの差分を追いやすくなります。

actions/checkout の最新タグは git ls-remote --tags https://github.com/actions/checkout.git で確認できます。 時点依存の action なので、公開前に一度見直してください。

workflow を置いたら push します。

まだ GitHub 上にリポジトリを作成していない場合は、gh コマンドで作成して origin を設定します。

gh repo create php-github-actions-docker-compose-ci-demo --public
git remote add origin https://github.com/<ユーザー>/php-github-actions-docker-compose-ci-demo.git

gh が入っていない場合は、GitHub のウェブ画面からリポジトリを作成し、表示される git remote add コマンドをそのまま実行してください。リポジトリ名は上記を参考にしてください。

origin の設定が済んだらコミットして push します。

git add compose.yml docker composer.json composer.lock phpunit.xml src tests .github/workflows/ci.yml .env.example
git commit -m "Add Docker Compose PHPUnit workflow"
git push origin main

デフォルトブランチが main ではない場合は、workflow の branches: [main]git push origin main を自分のブランチ名へ読み替えてください。

GitHub Actionsのci workflowが全step成功している画面

5. PostgreSQL の起動待ちと docker compose exec -T を押さえる

この構成で詰まりやすいのは 2 つです。 db がまだ準備中なのにテストを始めることと、GitHub Actions の非対話環境で TTY を要求してしまうこと。

workflow から該当部分を抽出:

      - name: Wait for PostgreSQL server
        run: until docker compose exec -T db pg_isready -U app -d app; do sleep 1; done

      - name: Wait for test database
        run: until docker compose exec -T db psql -U app -d app_test -c "select 1" > /dev/null; do sleep 1; done

      - name: Run PHPUnit
        run: docker compose exec -T app composer test

役割の違い:

  • pg_isready -U app -d app
    • PostgreSQL サーバーが接続を受け付ける状態かを見る
  • psql -U app -d app_test -c "select 1"
    • app_test に実際に接続できるかを見る
  • docker compose exec -T app ...
    • GitHub Actions の非対話実行で pseudo-TTY を要求しない

pg_isready だけだと、サーバー自体は起動していても app_test の作成や 02-schema.sql の適用がまだ終わっていない場面を取りこぼします。 そのため、psql -d app_test -c "select 1" を続けて入れています。

docker compose exec は既定で TTY を割り当てるため、非対話環境では -T が要ります。 ローカルの対話ターミナルでは気づきにくいですが、GitHub Actions の step は非対話実行なので、そのままだと the input device is not a TTY が出ます。

6. 失敗ログの見方と if: failure() のログ採取

CI が落ちたときは、次の順で辿ると迷いません。

  1. GitHub の Actions タブを開く
  2. 対象の workflow run を開く
  3. db-test job を開く
  4. 赤くなっている step を先に開く
  5. そのあと Show container status on failure / Show db logs on failure / Show app logs on failure を見る

まず step 名で失敗箇所を絞り、その後に docker compose logs で広げる流れです。

if: failure() で採っているログ:

      - name: Show container status on failure
        if: failure()
        run: docker compose ps

      - name: Show db logs on failure
        if: failure()
        run: docker compose logs db

      - name: Show app logs on failure
        if: failure()
        run: docker compose logs app

代表的な失敗例を 3 つだけ載せます。

-T を忘れたとき

the input device is not a TTY
Error: Process completed with exit code 1.

app_test へまだ接続できないとき

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL:  database "app_test" does not exist
Error: Process completed with exit code 1.

PHPUnit が落ちたとき

There was 1 failure:

1) Tests\TaskRepositoryTest::testFindIncompleteTitlesStillReturnsTwoItems
Failed asserting that actual size 4 matches expected size 2.

composer test だけ落ちたなら、まずローカルで同じコマンドを再実行します。

docker compose exec app composer test

ローカルでも落ちるなら、CI ではなくテストコード側の問題です。 ローカルでは通るのに CI だけ落ちるなら、起動待ち、.env 用意、-T のいずれかを優先して疑います。

7. よくある詰まりどころを切り分ける

症状主原因まず見る場所戻る章
the input device is not a TTYdocker compose exec-T が無い.github/workflows/ci.ymlexec step5
database "app_test" does not exist01-create-test-db.sql 未反映、待機不足、ボリューム使い回し02-schema.sqldocker compose down -v、待機 step2,5
could not find driverpdo_pgsql 未導入、イメージ再ビルド漏れdocker/php/Dockerfiledocker compose up -d --build2,3
.env を読めず変数展開で失敗cp .env.example .env を忘れたworkflow の Prepare env file step4
ローカルでは通るのに Actions でだけ落ちる起動待ちか -T の不足、docker compose 差分Show container status on failuredocker compose logs db5,6
SQL を直したのに反映されないPostgres ボリュームが残っているdocker compose down -v2

切り分けに使う最小コマンド:

docker compose ps
docker compose logs db
docker compose logs app
docker compose exec db pg_isready -U app -d app
docker compose exec db psql -U app -d app_test -c "select 1"
docker compose exec app composer test

app コンテナは sleep infinity で起動しているだけなので、ログはほぼ出ません。 その代わり、composer test の step 失敗と db 側のログを合わせると、切り分けやすくなります。

8. まとめと次の一歩

この記事で扱った 3 点:

  • ローカルの app + db 構成をそのまま CI に載せる土台
  • pg_isreadypsql を分けた PostgreSQL 起動待ち
  • docker compose exec -T と failure 時ログを含む GitHub Actions workflow

DB を含むときは、runner 上の PHP より app コンテナへ寄せたほうが差分を追いやすいです。 composer test をこの形で安定させ、そのあと必要なら PHPStan / PHP CS Fixer を同じ app コンテナへ足します。

runner 上の PHP だけで十分なケースに戻りたいなら、GitHub ActionsでPHPUnit / PHP CS Fixer / PHPStanを回す最小CI のほうが軽い構成です。 DB テスト側の基礎から入りたい場合は、PHPUnitでDBテストを始める(PostgreSQL + Docker) が足がかりになります。