Skip to content

Commit 1b10bb2

Browse files
committed
Initial release: phpMyAdmin as a php-fpm image with its nginx
The upstream phpmyadmin/phpmyadmin image is rebuilt when phpMyAdmin is released, not when its base picks up fixes. Measured on 2026-08-08 with trivy: 4623 findings, 3396 of them with an available fix and 562 rated CRITICAL or HIGH, in an image built 2025-10-08 and 708 MB in size. Its `:latest` tag is byte-identical to `:5.2.3`, so waiting for an upstream rebuild is not a plan. This repository builds the same application on a base we refresh daily. The pinned variant ships phpMyAdmin's libraries exactly as released and reports 18 findings, all of them in that bundled vendor tree. The optional rolling variant refreshes those libraries within phpMyAdmin's own constraints — twig moves from the released 3.11.3 to 3.28.0 — and reports none at all. The image is 157 MB and runs as www-data. Everything is verified against a running stack rather than asserted: both variants boot, log in against a MariaDB through the real form and CSRF token, serve their static files from nginx, and refuse /ping and /status from anywhere but loopback. That last one is not theory — the obvious-looking allow-list for the private ranges publishes those endpoints to everyone, because in Docker each request through a published port arrives from the bridge gateway. Two defects the test runs surfaced and that are fixed here: a named volume shared with nginx would be seeded once and then serve the old release forever after an image update, so an assets service re-syncs it on every start; and generating the blowfish secret through `tr ... | head -c 32` kills the entrypoint with SIGPIPE under pipefail, which is precisely the path anyone takes who starts the image without setting the secret. Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
0 parents  commit 1b10bb2

29 files changed

Lines changed: 1800 additions & 0 deletions

.dockerignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!rootfs/

.editorconfig

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
insert_final_newline = true
7+
trim_trailing_whitespace = true
8+
indent_style = space
9+
indent_size = 4
10+
11+
[*.{yml,yaml,json}]
12+
indent_size = 2
13+
14+
[Makefile]
15+
indent_style = tab

.env.example

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Copy to .env and adjust. Every value here has a working default in
2+
# compose.yml; this file exists to show what can be changed.
3+
4+
# --- image ------------------------------------------------------------------
5+
PMA_IMAGE_TAG=latest
6+
# Build-time only, used by `make build`:
7+
PHP_VERSION=8.4
8+
PMA_VERSION=5.2.3
9+
10+
# --- the database phpMyAdmin administers -------------------------------------
11+
PMA_HOST=db
12+
PMA_PORT=3306
13+
14+
# --- session ----------------------------------------------------------------
15+
# Exactly 32 characters. Without it the container generates one and keeps it in
16+
# the pma-tmp volume; losing that volume then logs everyone out.
17+
# tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32
18+
PMA_BLOWFISH_SECRET=
19+
20+
# Set when phpMyAdmin is served under a path or a different public hostname
21+
# than the container sees, e.g. https://admin.example.org/pma/
22+
PMA_ABSOLUTE_URI=
23+
24+
# --- limits -----------------------------------------------------------------
25+
UPLOAD_LIMIT=256M
26+
MEMORY_LIMIT=512M
27+
MAX_EXECUTION_TIME=600
28+
29+
# --- web --------------------------------------------------------------------
30+
PMA_HTTP_PORT=8080
31+
NGINX_VERSION=1.29-alpine
32+
# Where nginx finds php-fpm. Change it when the app service is named
33+
# differently, or when php-fpm runs outside this compose project.
34+
PMA_FPM_UPSTREAM=app:9000
35+
36+
# --- standalone profile only -------------------------------------------------
37+
# `docker compose --profile standalone up -d` starts a database as well.
38+
MARIADB_ROOT_PASSWORD=
39+
MARIADB_DATABASE=demo
40+
DB_IMAGE=mariadb
41+
DB_IMAGE_VERSION=11.8
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 Netresearch DTT GmbH
3+
4+
name: Auto-merge dependency PRs
5+
6+
on:
7+
pull_request:
8+
9+
permissions: {}
10+
11+
jobs:
12+
auto-merge:
13+
uses: netresearch/.github/.github/workflows/auto-merge-deps.yml@main
14+
permissions:
15+
contents: write
16+
pull-requests: write

.github/workflows/build.yml

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 Netresearch DTT GmbH
3+
#
4+
# The daily rebuild is the whole point of this repository: upstream refreshes
5+
# its image when phpMyAdmin is released, we refresh ours when its base picks up
6+
# fixes. Two variants per run — `pinned` ships the libraries as released,
7+
# `rolling` refreshes them within phpMyAdmin's own constraints.
8+
9+
name: build
10+
11+
on:
12+
schedule:
13+
- cron: '0 3 * * *' # 03:00 UTC, before the workday
14+
push:
15+
branches: [main]
16+
paths-ignore:
17+
- 'docs/**'
18+
- 'examples/**'
19+
- '**.md'
20+
pull_request:
21+
workflow_dispatch:
22+
23+
env:
24+
REGISTRY: ghcr.io
25+
IMAGE_NAME: ${{ github.repository_owner }}/phpmyadmin-php-fpm
26+
27+
permissions: {}
28+
29+
concurrency:
30+
group: build-${{ github.ref }}
31+
# A scheduled multi-arch rebuild must not be interrupted half-pushed.
32+
cancel-in-progress: ${{ github.event_name != 'schedule' }}
33+
34+
jobs:
35+
build:
36+
name: ${{ matrix.variant }}
37+
runs-on: ubuntu-latest
38+
permissions:
39+
contents: read
40+
packages: write
41+
id-token: write
42+
attestations: write
43+
strategy:
44+
fail-fast: false
45+
matrix:
46+
include:
47+
- variant: pinned
48+
rolling: "false"
49+
suffix: ""
50+
- variant: rolling
51+
rolling: "true"
52+
suffix: "-rolling"
53+
steps:
54+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
55+
with:
56+
persist-credentials: false
57+
58+
- name: Read the pinned phpMyAdmin release
59+
id: version
60+
run: echo "pma=$(cat .phpmyadmin-version)" >> "$GITHUB_OUTPUT"
61+
62+
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
63+
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
64+
65+
# Pushing only happens from main and the schedule: a pull request must be
66+
# able to prove the image still builds without being able to publish it.
67+
- name: Log in to the registry
68+
if: github.event_name != 'pull_request'
69+
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
70+
with:
71+
registry: ${{ env.REGISTRY }}
72+
username: ${{ github.actor }}
73+
password: ${{ secrets.GITHUB_TOKEN }}
74+
75+
- name: Tags and labels
76+
id: meta
77+
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
78+
with:
79+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
80+
flavor: |
81+
suffix=${{ matrix.suffix }},onlatest=true
82+
tags: |
83+
type=raw,value=latest,enable={{is_default_branch}}
84+
type=raw,value=${{ steps.version.outputs.pma }},enable={{is_default_branch}}
85+
type=raw,value=${{ steps.version.outputs.pma }}-{{date 'YYYYMMDD'}},enable={{is_default_branch}}
86+
type=ref,event=pr
87+
88+
- name: Build and push
89+
id: push
90+
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
91+
with:
92+
context: .
93+
platforms: linux/amd64,linux/arm64
94+
push: ${{ github.event_name != 'pull_request' }}
95+
load: ${{ github.event_name == 'pull_request' }}
96+
tags: ${{ steps.meta.outputs.tags }}
97+
labels: ${{ steps.meta.outputs.labels }}
98+
cache-from: type=gha,scope=${{ matrix.variant }}
99+
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
100+
build-args: |
101+
PMA_VERSION=${{ steps.version.outputs.pma }}
102+
ROLLING_DEPS=${{ matrix.rolling }}
103+
BUILD_DATE=${{ github.event.repository.updated_at }}
104+
VCS_REF=${{ github.sha }}
105+
# The SBOM and provenance attestations travel with the manifest, so
106+
# a consumer can ask the registry what is inside the image it pulled.
107+
sbom: true
108+
provenance: mode=max
109+
110+
- name: Attest the build
111+
if: github.event_name != 'pull_request'
112+
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
113+
with:
114+
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
115+
subject-digest: ${{ steps.push.outputs.digest }}
116+
push-to-registry: true
117+
118+
# Proves the image actually serves phpMyAdmin, not merely that it built.
119+
# A dependency refresh that breaks the application would pass every static
120+
# check and fail here.
121+
smoke-test:
122+
name: smoke test (${{ matrix.variant }})
123+
needs: build
124+
if: github.event_name != 'pull_request'
125+
runs-on: ubuntu-latest
126+
permissions:
127+
contents: read
128+
packages: read
129+
strategy:
130+
fail-fast: false
131+
matrix:
132+
variant: [pinned, rolling]
133+
steps:
134+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
135+
with:
136+
persist-credentials: false
137+
138+
- uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
139+
with:
140+
registry: ${{ env.REGISTRY }}
141+
username: ${{ github.actor }}
142+
password: ${{ secrets.GITHUB_TOKEN }}
143+
144+
- name: Start the stack
145+
run: |
146+
set -euo pipefail
147+
cp .env.example .env
148+
{
149+
echo "MARIADB_ROOT_PASSWORD=smoke-test-only"
150+
echo "PMA_BLOWFISH_SECRET=smoketestsmoketestsmoketest12345"
151+
if [ "${{ matrix.variant }}" = "rolling" ]; then
152+
echo "PMA_IMAGE_TAG=latest-rolling"
153+
fi
154+
} >> .env
155+
docker compose --profile standalone up -d --wait --wait-timeout 300
156+
157+
- name: phpMyAdmin answers and logs in
158+
run: |
159+
set -euo pipefail
160+
jar=$(mktemp)
161+
code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/)
162+
[ "$code" = "200" ] || { echo "::error::login page returned $code"; exit 1; }
163+
164+
token=$(curl -sS -c "$jar" http://127.0.0.1:8080/index.php \
165+
| grep -oE 'name="token" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//')
166+
[ -n "$token" ] || { echo "::error::no CSRF token in the login form"; exit 1; }
167+
168+
curl -sS -b "$jar" -c "$jar" -o /dev/null \
169+
-X POST -d "pma_username=root&pma_password=smoke-test-only&server=1&target=index.php&token=$token" \
170+
http://127.0.0.1:8080/index.php
171+
# The authenticated page names the server it is connected to; a
172+
# failed login would render the form again instead.
173+
curl -sS -b "$jar" http://127.0.0.1:8080/index.php | grep -q 'db via TCP/IP' \
174+
|| { echo "::error::login did not reach the database"; exit 1; }
175+
echo "logged in against the database"
176+
177+
- name: php-fpm endpoints are not public
178+
run: |
179+
set -euo pipefail
180+
for path in ping status; do
181+
code=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8080/$path")
182+
[ "$code" = "403" ] || { echo "::error::/$path answered $code, expected 403"; exit 1; }
183+
done
184+
185+
- name: Logs on failure
186+
if: failure()
187+
run: docker compose --profile standalone logs

.github/workflows/lint.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 Netresearch DTT GmbH
3+
#
4+
# Static checks. The Dockerfile and shell linting come from the shared
5+
# reusable so the ruleset stays identical across our container repos; the
6+
# compose validation stays here because it needs this repo's own placeholders.
7+
8+
name: lint
9+
10+
on:
11+
push:
12+
branches: [main]
13+
pull_request:
14+
workflow_dispatch:
15+
16+
permissions: {}
17+
18+
jobs:
19+
container-lint:
20+
uses: netresearch/.github/.github/workflows/lint-container.yml@main
21+
permissions:
22+
contents: read
23+
with:
24+
shell-scandirs: ./rootfs/usr/local/bin
25+
26+
compose-validate:
27+
name: docker compose config
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
steps:
32+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
33+
with:
34+
persist-credentials: false
35+
36+
- name: Validate compose.yml
37+
run: |
38+
set -euo pipefail
39+
cp .env.example .env
40+
# The demo profile refuses to render without a database password,
41+
# which is the point of it being required — supply a throwaway one
42+
# so the validation exercises every service.
43+
printf 'MARIADB_ROOT_PASSWORD=validate-only\n' >> .env
44+
docker compose config -q
45+
docker compose --profile standalone config -q
46+
47+
- name: Validate the overlays
48+
run: |
49+
set -euo pipefail
50+
PMA_PUBLIC_HOST=pma.example.org \
51+
PMA_BASICAUTH='user:$$apr1$$placeholder' \
52+
docker compose -f compose.yml -f examples/compose.traefik.yml config -q

.github/workflows/pr-quality.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 Netresearch DTT GmbH
3+
4+
name: pr-quality
5+
6+
on:
7+
pull_request:
8+
types: [opened, edited, synchronize, reopened]
9+
10+
permissions: {}
11+
12+
jobs:
13+
pr-quality:
14+
uses: netresearch/.github/.github/workflows/pr-quality.yml@main
15+
permissions:
16+
contents: read
17+
pull-requests: read

.github/workflows/security.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 Netresearch DTT GmbH
3+
#
4+
# Scans the published images. Runs after the daily build so the numbers in the
5+
# README stay checkable rather than aspirational.
6+
7+
name: security
8+
9+
on:
10+
workflow_run:
11+
workflows: [build]
12+
types: [completed]
13+
schedule:
14+
- cron: '30 5 * * *'
15+
workflow_dispatch:
16+
17+
permissions: {}
18+
19+
jobs:
20+
trivy:
21+
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
tag: [latest, latest-rolling]
26+
uses: netresearch/.github/.github/workflows/security-container.yml@main
27+
permissions:
28+
contents: read
29+
packages: read
30+
security-events: write
31+
with:
32+
image-ref: ghcr.io/${{ github.repository_owner }}/phpmyadmin-php-fpm:${{ matrix.tag }}
33+
sarif-category: trivy-${{ matrix.tag }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.env
2+
compose.override.yml

.hadolint.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ignored:
2+
# Pinning apk versions in an image whose entire purpose is to rebuild
3+
# against current packages would defeat the point.
4+
- DL3018
5+
failure-threshold: warning

0 commit comments

Comments
 (0)