Tutorial: set up ephemeral Docker runners on GitHub Actions with remote cache to speed up PHP monolith builds — step by step

Deploy & DevOps · By · Published on · 10 min read

Dual escalator inside a modern interior with linear light panels above
A modern composition suggesting movement and the flow of infrastructure processes. Photo: Thiestro / Pixabay

Step-by-step tutorial to set up ephemeral Docker runners for GitHub Actions with remote cache to speed up builds of PHP monoliths. You’ll see the architecture, the runner image, Docker Compose orchestration, the BuildKit/Buildx YAML workflow, and Dockerfile adjustments to maximize cache reuse between runs while keeping isolation.

Prerequisites and when this makes sense

Ephemeral runners are ideal when you want per-job isolation, high parallelism and zero accumulated state between runs. The trade-off is losing local caches; that’s why we use remote cache.

This guide — setting up ephemeral Docker runners on GitHub Actions with remote cache to speed up PHP monolith builds — focuses on PHP monoliths (e.g., Laravel apps) that build Docker images.

You need a Linux host with Docker and permission to create runner containers. The GitHub repository must allow self-hosted runners.

  • Linux host with Docker and Docker Compose (or the Compose plugin).
  • Access to the GitHub repository or organization to create a runner token.
  • Optional: Git and SSH if you pull submodules.
  • Secure variables for the PAT (if you fetch the token via the API).

Architecture: ephemeral runner + Docker + remote cache

Each job starts in a container running the GitHub Actions Runner, registers with a short-lived token, executes a single job and then shuts down. Docker builds run with BuildKit/Buildx using a remote cache backend that persists between runs.

Because the runner is destroyed after the job, the host’s local Docker cache is not useful. We therefore adopt BuildKit remote cache backends that suit ephemeral environments. Common options include:

Cache backend Persists between runners? Complexity When to use
local (default) No Low Fixed environments or persistent runners.
registry (type=registry) Yes Medium You control a registry; good for larger teams.
GHA (type=gha) Yes Low Integrates with GitHub Actions; great to get started.

Build the ephemeral runner Docker image

We use the official runner image as a base and an entrypoint that registers ephemeral runners, runs the job and removes the runner. Mount the host Docker socket into the container to allow Docker-in-runner builds. Mounting the socket grants elevated privileges to the container — use a dedicated host and access policies.

# Crie Dockerfile e entrypoint do runner
mkdir -p runner-image && cd runner-image

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM ghcr.io/actions/runner:latest

# Instale Docker CLI para usar o socket do host a partir do runner
USER root
RUN apt-get update \ 
 && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ 
 && install -m 0755 -d /etc/apt/keyrings \ 
 && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \ 
 && chmod a+r /etc/apt/keyrings/docker.gpg \ 
 && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ 
 && apt-get update \ 
 && apt-get install -y --no-install-recommends docker-ce-cli \ 
 && apt-get purge -y --auto-remove curl gnupg \ 
 && rm -rf /var/lib/apt/lists/*

# Entrypoint para registro efêmero
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

USER 1001
ENTRYPOINT ["/entrypoint.sh"]
EOF

cat > entrypoint.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

: "${RUNNER_URL:?Defina RUNNER_URL (repo ou org)}"
: "${RUNNER_TOKEN:?Defina RUNNER_TOKEN válido}"
: "${RUNNER_LABELS:=docker,linux,x64,ephemeral}"
: "${RUNNER_NAME:=runner-$(hostname)-$RANDOM}"
: "${RUNNER_WORKDIR:=/_work}"

cleanup() {
  echo "Removendo runner..."
  ./config.sh remove --unattended --token "$RUNNER_TOKEN" || true
}
trap cleanup EXIT

./config.sh \
  --url "$RUNNER_URL" \
  --token "$RUNNER_TOKEN" \
  --work "$RUNNER_WORKDIR" \
  --labels "$RUNNER_LABELS" \
  --name "$RUNNER_NAME" \
  --ephemeral \
  --unattended \
  --replace

exec ./run.sh
EOF

# Construa a imagem do runner
docker build -t gha-ephemeral-runner:latest .

Orchestrate runners with Docker Compose and dynamic token

Registration tokens expire quickly, so generate a fresh token before starting each runner. Below is a simple script that uses a PAT with the proper scope to request a registration token. Protect the PAT in a secure environment variable. You can scale multiple runners with Compose.

To control host resources, see how to limit CPU/memory on containers in the cgroup guide: cgroup v2 + systemd for Docker.

# Arquivos: get-token.sh, .env e docker-compose.yml
mkdir -p ../runner-compose && cd ../runner-compose

# Script para obter token de registro (repo)
cat > get-token.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# Variáveis exigidas: GH_OWNER, GH_REPO, GH_PAT
: "${GH_OWNER:?}"
: "${GH_REPO:?}"
: "${GH_PAT:?}"

resp=$(curl -s -X POST \
  -H "Authorization: token ${GH_PAT}" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/runners/registration-token)

echo "$resp" | jq -r .token
EOF
chmod +x get-token.sh

# Variáveis do ambiente do Compose
cat > .env <<'EOF'
GH_OWNER=seu-usuario-ou-org
GH_REPO=seu-repo
# Export GH_PAT no shell ao rodar o compose para não gravar no disco
RUNNER_URL=https://github.com/${GH_OWNER}/${GH_REPO}
RUNNER_LABELS=docker,linux,x64,ephemeral
EOF

# Docker Compose do runner efêmero
cat > docker-compose.yml <<'EOF'
services:
  gha-runner:
    image: gha-ephemeral-runner:latest
    restart: "no"
    environment:
      RUNNER_URL: ${RUNNER_URL}
      RUNNER_LABELS: ${RUNNER_LABELS}
      RUNNER_TOKEN: ${RUNNER_TOKEN}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 4g
EOF

# Exemplo: iniciar 2 runners efêmeros
export GH_PAT=coloque_seu_token_aqui
export RUNNER_TOKEN=$(./get-token.sh)
RUNNER_TOKEN="$RUNNER_TOKEN" docker compose up -d --scale gha-runner=2

GitHub Actions workflow with BuildKit and remote cache

Now configure the workflow to target the self-hosted runner via labels and enable BuildKit remote cache (type=gha) plus Composer caching. If your pipeline builds multi-architecture images, see the multi-arch image guide: How to build multi-architecture Docker images. For BuildKit remote cache fundamentals, see: BuildKit remote cache in CI.

With this setup, you apply the tutorial in practice: ephemeral Docker runners on GitHub Actions with remote cache to speed up PHP monolith builds and reduce build time predictably.

name: CI PHP (runner efêmero)

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  build:
    runs-on: [self-hosted, linux, x64, ephemeral]
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configurar Buildx
        uses: docker/setup-buildx-action@v3

      - name: Cache do Composer
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/composer
          key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            composer-${{ runner.os }}-

      - name: Instalar dependências PHP (Composer)
        run: |
          echo "COMPOSER_CACHE_DIR=$HOME/.cache/composer" >> $GITHUB_ENV
          composer install --no-interaction --prefer-dist

      - name: Build da imagem com cache remoto (BuildKit)
        env:
          DOCKER_BUILDKIT: 1
        run: |
          docker buildx build \
            --cache-from type=gha \
            --cache-to type=gha,mode=max \
            -t ghcr.io/${{ github.repository }}/app:${{ github.sha }} \
            .

      - name: Testes (exemplo)
        run: |
          vendor/bin/phpunit --colors=always --testdox

Tune your Dockerfile for cacheable layers in PHP monoliths

To further speed up builds, structure your Dockerfile with multistage and BuildKit cache mounts. Separate PHP dependencies (Composer) from application code, minimize invalidations, and use selective COPY. The example below uses Composer 2 as the deps stage and PHP-FPM as runtime. Adapt extensions and php.ini to your project.

# syntax=docker/dockerfile:1.7

########## deps ##########
FROM composer:2 AS deps
WORKDIR /app

# Apenas arquivos que influenciam as deps
COPY composer.json composer.lock ./
# Cache do Composer isolado
RUN --mount=type=cache,target=/tmp/composer-cache \
    COMPOSER_CACHE_DIR=/tmp/composer-cache \
    composer install --no-dev --no-interaction --prefer-dist --no-progress

########## builder ##########
FROM php:8.2-fpm-alpine AS builder
WORKDIR /app

# Extensões necessárias (exemplo)
RUN apk add --no-cache icu-dev libzip-dev oniguruma-dev git \
 && docker-php-ext-install intl zip mbstring

# Copie apenas o necessário para build
COPY --from=deps /app/vendor /app/vendor
COPY . .

# Opcional: build de assets se houver (npm/yarn) usando cache
# RUN --mount=type=cache,target=/root/.npm \
#     npm ci && npm run build

########## runtime ##########
FROM php:8.2-fpm-alpine AS runtime
WORKDIR /var/www/html

# Extensões de runtime
RUN apk add --no-cache icu libzip oniguruma \
 && docker-php-ext-install opcache

# Ajustes de opcache (exemplo)
RUN { \
  echo 'opcache.enable=1'; \
  echo 'opcache.validate_timestamps=0'; \
  echo 'opcache.jit=1255'; \
} > /usr/local/etc/php/conf.d/opcache.ini

COPY --from=builder /app /var/www/html

HEALTHCHECK --interval=30s --timeout=3s CMD php -v || exit 1

CMD ["php-fpm"]

Verification and troubleshooting

In the job log, the runner name will indicate a self-hosted execution. After the Buildx step, look for cache hit/miss messages. When the remote cache works, stable layers show quick retrieval.

In the Composer step, actions/cache will report whether the primary or a partial key was restored. With an unchanged composer.lock, installs will be visibly faster on re-runs.

Invalid or expired token: generate a new one immediately before docker compose up. Check PAT scopes.

Builds without cache: ensure DOCKER_BUILDKIT=1, use docker/setup-buildx-action and keep --cache-from/--cache-to flags consistent.

Error accessing /var/run/docker.sock: check socket permissions on the host and that the volume is mounted.

Network instability: remote caches depend on solid connectivity; consider adding retry logic in the job.

  • Check runner labels in the job header.
  • Look for “Loaded cache”/“Reusing cache” in the build output.
  • Check the actions/cache summary for Composer.
  • Validate RUNNER_URL, RUNNER_TOKEN and that labels match runs-on.
  • Keep the Dockerfile stable to maximize reuse.
  • Avoid copying the entire repo before installing deps in the Dockerfile.

Security and next steps

Mounting the Docker socket gives the container full control over the host. Isolate the host, limit who can trigger workflows and audit secrets. Consider using disposable hosts or reimage policies. See the CI/CD security checklist: CI/CD security checklist: runners & secrets.

To scale, implement autoscaling that creates runner containers on demand via webhooks or queues. Guide: Auto-scale self-hosted GitHub Actions runners for PHP CI. If your pipeline publishes multi-arch images, see: How to build multi-architecture Docker images. For BuildKit caching best practices in CI, see: BuildKit remote cache in CI.

  • Use host-specific networks and firewalls for runner hosts.
  • Restrict PATs and tokens, and rotate them frequently.
  • Monitor resource usage and set appropriate limits on the host.

Frequently asked questions

What’s the difference between an ephemeral runner and a persistent runner?

An ephemeral runner registers, runs a single job and removes itself. It doesn’t keep local state, reducing drift and risk. A persistent runner stays registered, can accumulate dependencies and local caches which speed builds, but requires more maintenance and hardening.

Does BuildKit remote cache work with any Dockerfile?

It works, but gains depend on Dockerfile structure. Multistage builds, COPY order and cache mounts help a lot. If layers change constantly, cache benefits are limited. Adjust the Dockerfile to maximize reuse.

Do I need a private registry for remote cache?

No. The backend type=gha stores cache in GitHub Actions. Private registries are useful if you already use them and want cache reuse across projects or outside GitHub.

Is it safe to mount the Docker socket into the runner?

It’s risky: mounting the socket grants root-level control over the host. Use a dedicated host, restrict who can run pipelines, limit token scopes and monitor activity. Alternatives include Docker-in-Docker for extra isolation, at the cost of complexity and performance.

How do I obtain a runner registration token?

Request one via the GitHub API using a Personal Access Token with the correct scopes and call the repository (or organization) endpoint to create a runner registration token. Tokens are short-lived, so fetch one just before starting the runner container.

Should Composer cache vendor or only Composer’s internal cache?

Prefer Composer’s internal cache (~/.cache/composer) via actions/cache. It speeds up downloads without risking inconsistencies. Caching vendor can help but requires keys per OS/PHP/extensions and may invalidate with small changes.

Want to adapt this setup to your PHP pipeline?

We can review your Dockerfile, workflow and cache strategy to safely cut build time.

Discuss CI with ephemeral runners

See also