この 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 側ターミナルで実行します。
サービス名は app と db、テスト用 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 でも app と app_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"
確認ポイント:
appとapp_testが見えるappとapp_testの両方にtasksテーブルがあるpg_isreadyはサーバー受付確認、psql -d app_test -c "select 1"はテスト用 DB への接続確認になっている
詰まったとき:
docker compose logs db
docker compose down -v
docker compose up -d --build
docker-entrypoint-initdb.d の SQL は初回起動時だけ走ります。
01-create-test-db.sql や 02-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 --strict と composer install --prefer-dist は、この lock を前提に再現します。
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/Dockerfileのpdo_pgsqlとdocker compose up -d --buildを確認するrelation "tasks" does not exist: 2章へ戻り、app_test側のtasksテーブルを確認するAPP_DB_NAMEが切り替わらない:phpunit.xmlのforce="true"を確認する
コードのポイント
① getenv() ?: で環境変数から DSN を組み立てる
tests/DatabaseTestCase.php の 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'
);
getenv() が空のときに ?: でデフォルト値を補う形にしている。
.env と phpunit.xml の force="true" の切り替えが効いていれば、APP_DB_NAME は app_test になる。
② TRUNCATE TABLE tasks RESTART IDENTITY でテストを毎回リセットする
tests/DatabaseTestCase.php の setUp() 末尾のポイントはここ。
$this->pdo->exec('TRUNCATE TABLE tasks RESTART IDENTITY');
setUp() で呼ぶことで、テストメソッドが始まるたびにテーブルをゼロに戻す。
RESTART IDENTITY は BIGSERIAL のシーケンスも初期値に戻すため、ID の並びがテスト間でぶれない。
③ array_map + 型付きクロージャで行を文字列に変換する
src/TaskRepository.php の findIncompleteTitles() のポイントはここ。
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 --buildでappとdbを起動するcomposer validate --strict/composer install/composer testはすべてappコンテナ内で実行する- 失敗時に
psとlogsを残し、最後は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 を自分のブランチ名へ読み替えてください。
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 が落ちたときは、次の順で辿ると迷いません。
- GitHub の
Actionsタブを開く - 対象の workflow run を開く
db-testjob を開く- 赤くなっている step を先に開く
- そのあと
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 TTY | docker compose exec に -T が無い | .github/workflows/ci.yml の exec step | 5 |
database "app_test" does not exist | 01-create-test-db.sql 未反映、待機不足、ボリューム使い回し | 02-schema.sql、docker compose down -v、待機 step | 2,5 |
could not find driver | pdo_pgsql 未導入、イメージ再ビルド漏れ | docker/php/Dockerfile、docker compose up -d --build | 2,3 |
.env を読めず変数展開で失敗 | cp .env.example .env を忘れた | workflow の Prepare env file step | 4 |
| ローカルでは通るのに Actions でだけ落ちる | 起動待ちか -T の不足、docker compose 差分 | Show container status on failure、docker compose logs db | 5,6 |
| SQL を直したのに反映されない | Postgres ボリュームが残っている | docker compose down -v | 2 |
切り分けに使う最小コマンド:
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_isreadyとpsqlを分けた 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) が足がかりになります。