Skip to content

YourBOX Verification Baseline

The first work item on YourBOX is making the suite runnable and the image trustworthy. The 2020 setup pinned three different PHP versions across mise.toml, the Dockerfile and the CI test job, shipped an Apache mod_php image with a baked secrets file, and never passed hadolint or Trivy. None of the modernization ADRs can self verify until this baseline is in place.

YourBOX runs on PHP 8.5. The version is pinned in three coordinated places:

  • mise.toml selects PHP 8.5 through ubi:adwinying/php, plus composer, ffmpeg, yamllint, hadolint, trivy, sops, age, and pre-commit.
  • The runtime Dockerfile uses docker.io/library/php:8.5-fpm-alpine with its current digest pinned per ADR-0009.
  • The GitLab CI test job uses docker.io/library/php:8.5-cli-alpine with its current digest pinned the same way.

The pipe operator already used in YourBOX/src/inc/functions.php requires PHP 8.4 or above. PHP 8.5 keeps the alignment forward compatible.

The runtime image is a two stage build: a composer:2 stage installs production dependencies for both YourBOX/composer.json (legacy tree) and app/composer.json (Symfony 8 skeleton from plan 003) into two side-by-side vendor directories, and a php:8.5-fpm-alpine runtime stage carries nginx, php-fpm, ffmpeg, tini, both code trees and the pre-installed vendors. nginx roots itself at app/public/ so the Symfony front controller handles every request and the strangler bundle includes legacy PHP files internally.

FROM docker.io/library/composer:2@sha256:<digest> AS deps
WORKDIR /app
COPY YourBOX/composer.json YourBOX/composer.lock ./yourbox/
COPY app/composer.json app/composer.lock ./symfony/
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache \
composer install --working-dir=/app/yourbox --no-dev --no-interaction --no-progress --prefer-dist --no-scripts \
&& composer install --working-dir=/app/symfony --no-dev --no-interaction --no-progress --prefer-dist --no-scripts
FROM docker.io/library/php:8.5-fpm-alpine@sha256:<digest> AS runtime
RUN apk add --no-cache nginx ffmpeg tini wget \
&& docker-php-ext-install pdo_mysql \
&& addgroup --system --gid 10001 appgroup \
&& adduser --system --uid 10001 --ingroup appgroup appuser
WORKDIR /var/www/html
COPY --from=deps /app/yourbox/vendor ./YourBOX/vendor
COPY --from=deps /app/symfony/vendor ./app/vendor
COPY YourBOX/ ./YourBOX/
COPY app/ ./app/
COPY infra/docker/nginx.conf /etc/nginx/nginx.conf
COPY infra/docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf
COPY infra/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& mkdir -p /run/nginx /var/lib/nginx/tmp /var/log/nginx /var/cache/nginx app/var \
&& chown -R appuser:appgroup /var/www/html /run/nginx /var/lib/nginx /var/log/nginx /var/cache/nginx
USER 10001
RUN php app/bin/console cache:warmup --env=prod --no-debug || true
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]

The image runs as UID 10001, exposes 8080, ships a healthcheck, warms the Symfony prod cache at build time and never embeds secrets. BuildKit secret mounts are available for any private package source that requires authentication during build:

Terminal window
docker buildx build \
--secret id=composer_auth,src=secrets/composer_auth.json \
-t registry.yourbox.site/yourbox:0.1.0 .

The sidecar files live under infra/docker/:

  • nginx.conf listens on 8080, sets root /var/www/html/app/public, fastcgi passes to 127.0.0.1:9000 with the Symfony fallback (try_files $uri /index.php?$query_string), and routes every request through the Symfony front controller (which in turn lets the strangler bundle include legacy PHP).
  • php-fpm.conf runs as the appuser account with a dynamic pool sized for an alpine container.
  • entrypoint.sh starts php-fpm -D and execs nginx -g 'daemon off;' under tini.

Hadolint runs in the pre-commit hook and in the lint stage of the pipeline. The Dockerfile passes with zero warnings. Rules disabled globally are declared in .hadolint.yaml with a justification; no inline # hadolint ignore= comments are needed or used.

Trivy scans the image locally via mise run image:scan and in CI. The gate is HIGH,CRITICAL with --exit-code 1. A failure blocks the pipeline. Unfixable findings are tracked as exceptions in infra/security/trivy-allowlist.yaml with a CVE identifier, a justification and an expiry date.

Terminal window
mise run image:lint # hadolint Dockerfile
mise run image:scan # trivy image --severity HIGH,CRITICAL --exit-code 1 yourbox:dev

All local workflows go through mise run. The bootstrap entrypoint is make init; every other task uses the following mise tasks:

Task Description
mise run test Run unit and integration suites against the compose test stack
mise run test:unit Unit tests only (no database needed)
mise run test:integration Integration tests against the compose MySQL service
mise run lint:yaml yamllint with strict rules from .yamllint
mise run image:lint hadolint on the Dockerfile, rules from .hadolint.yaml
mise run image:build Build the YourBOX runtime image as yourbox:dev
mise run image:scan Trivy HIGH and CRITICAL scan on the locally built image
mise run image:smoke Build the image, run it, hit /health, tear down

The test tasks source credentials from dev.env (committed, placeholder values) which mise loads automatically via _.file. The compose.test.yml MySQL service uses inline test fixtures. mise run image:smoke consumes the same dev.env through docker --env-file, overriding APP_ENV=prod / APP_DEBUG=0 so the smoke test exercises the prod kernel cache path.

Every container image reference in YourBOX uses the immutable form <registry>/<image>:<tag>@sha256:<digest> as defined by ADR-0009 and enforced by the Plumber configuration in .plumber.yaml.

The digest is resolved with:

Terminal window
docker buildx imagetools inspect docker.io/library/php:8.5-fpm-alpine \
--format '{{.Manifest.Digest}}'

The Plumber controls active for YourBOX are:

  • containerImageMustNotUseForbiddenTags rejects latest, dev, staging, main, master.
  • containerImagesMustBePinnedByDigest requires the @sha256: suffix.
  • containerImageMustComeFromAuthorizedSources enforces the registry allowlist.
  • branchMustBeProtected and includesMustBeUpToDate are enabled but will report non-compliance until plan 013 wires up branch protection and CI template components.

The PHPUnit setup unifies under a root phpunit.xml.dist that covers two suites: unit under tests/unit/ and integration under tests/integration/. Composer scripts wrap each suite:

Terminal window
composer test # all suites
composer test:unit
composer test:integration

The integration suite connects to a MySQL 8.4 container spun up by compose.test.yml. Local credentials come from dev.env; CI uses hard coded test-only values. The DSN uses the correct lowercase dbname= key; the earlier DB_NAME= typo is fixed.

The minimum coverage gate and functional tests are deferred to later plans pending Symfony and Postgres migration.

The CI test job uses pinned digest images and a dedicated composer:install pre-stage to avoid executing unverified remote scripts:

composer:install:
stage: .pre
image: docker.io/library/composer:2@sha256:<digest>
script:
- composer install --no-interaction --no-progress --prefer-dist --working-dir=YourBOX
test:integration:
image: docker.io/library/php:8.5-cli-alpine@sha256:<digest>
services:
- name: docker.io/library/mysql:8.4@sha256:<digest>
alias: mysql
variables:
DB_DSN: "mysql://root:test@mysql:3306/YourBOX"

The pre-commit configuration combines gitleaks, yamllint, hadolint, and the conventional-pre-commit hook that enforces Conventional Commits on every commit message:

---
default_install_hook_types: [pre-commit, commit-msg]
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
- repo: https://github.com/adrienverge/yamllint
rev: v1.37.1
hooks:
- id: yamllint
- repo: https://github.com/hadolint/hadolint
rev: v2.14.0
hooks:
- id: hadolint-docker
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.6.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [--strict, --force-scope, build, chore, ci, docs, feat, fix, ...]

A push that bypasses pre-commit is caught in CI through the same hooks executed by the lint stage.

The baseline is considered met when every item below holds. The CI pipeline encodes each step.

  • mise install reports PHP 8.5 and pinned tooling versions.
  • mise run image:lint exits 0 with zero warnings.
  • mise run image:build produces an image and mise run image:scan exits 0.
  • mise run image:smoke exits 0 confirming the health endpoint responds.
  • mise run test exits 0 across unit and integration suites.
  • grep -nE 'image: ?[^@]+$' .gitlab-ci/ Dockerfile returns no matches.
  • grep -nE 'FROM [^@]+$' Dockerfile returns no matches.
  • pre-commit run --all-files exits 0.