New approach with helm chart and add gd and opcache
Publish PHP Apache image / Build, test and push (push) Failing after 21s

This commit is contained in:
2026-09-10 20:13:16 +02:00
parent 9ebd626a42
commit b116ce8b80
14 changed files with 607 additions and 5 deletions
+150
View File
@@ -0,0 +1,150 @@
name: Publish PHP Apache image
on:
push:
workflow_dispatch:
schedule:
# Refresh the rolling Arch Linux base weekly.
- cron: "13 3 * * 1"
env:
IMAGE: registry.brunner.ninja/feedc0de/php-apache
jobs:
publish:
name: Build, test and push
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Validate Helm chart
run: |
set -euo pipefail
docker run --rm \
--volume "${PWD}:/source" \
--workdir /source \
alpine/helm:4.2.2@sha256:ee6fe3e96d9f8ea8dd1af9ecd7bbb3e233616a25f145392376f020fd2a51eb33 \
lint helm/php-homepage
docker run --rm \
--volume "${PWD}:/source" \
--workdir /source \
alpine/helm:4.2.2@sha256:ee6fe3e96d9f8ea8dd1af9ecd7bbb3e233616a25f145392376f020fd2a51eb33 \
template ci-test helm/php-homepage > /dev/null
- name: Log in to Quay
uses: docker/login-action@v3
with:
registry: registry.brunner.ninja
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }}
- name: Build image
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
docker build \
--pull \
--load \
--label "org.opencontainers.image.revision=${COMMIT_SHA}" \
--label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/php-apache" \
--tag "${IMAGE}:${COMMIT_SHA}" \
.
- name: Test Apache and PHP extensions
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
docker run --rm --entrypoint httpd "${IMAGE}:${COMMIT_SHA}" -t
docker run --rm --entrypoint php "${IMAGE}:${COMMIT_SHA}" -r '
foreach (["exif", "gd", "intl", "Zend OPcache", "pgsql", "pdo_pgsql"] as $extension) {
if (!extension_loaded($extension)) {
fwrite(STDERR, "Missing PHP extension: {$extension}\n");
exit(1);
}
}
$info = gd_info();
foreach (["JPEG Support", "PNG Support", "WebP Support"] as $feature) {
if (empty($info[$feature])) {
fwrite(STDERR, "Missing GD feature: {$feature}\n");
exit(1);
}
}
$image = imagecreatetruecolor(8, 8);
$scaled = imagescale($image, 4, 4);
if ($scaled === false || imagesx($scaled) !== 4 || imagesy($scaled) !== 4) {
fwrite(STDERR, "GD resize smoke test failed.\n");
exit(1);
}
echo "GD resize smoke test passed.\n";
'
- name: Determine version tag
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
version_tag="$(
docker run --rm --entrypoint sh "${IMAGE}:${COMMIT_SHA}" -ec '
apache_version="$(httpd -v | sed -n "s#^Server version: Apache/\([0-9.]*\).*$#\1#p")"
php_version="$(php -r "echo PHP_VERSION;")"
test -n "${apache_version}"
test -n "${php_version}"
printf "%s-%s" "${apache_version}" "${php_version}"
'
)"
printf 'VERSION_TAG=%s\n' "${version_tag}" >> "${GITHUB_ENV}"
run_attempt="${GITHUB_RUN_ATTEMPT:-1}"
test -n "${GITHUB_RUN_NUMBER}"
chart_version="0.${GITHUB_RUN_NUMBER}.${run_attempt}"
printf 'CHART_VERSION=%s\n' "${chart_version}" >> "${GITHUB_ENV}"
printf 'BUILD_TAG=chart-%s\n' "${chart_version}" >> "${GITHUB_ENV}"
- name: Push commit tag
if: gitea.event_name == 'push'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: docker push "${IMAGE}:${COMMIT_SHA}"
- name: Push main tags
if: gitea.ref == 'refs/heads/main' || gitea.event_name == 'schedule'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:${BUILD_TAG}"
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:${VERSION_TAG}"
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest"
docker push "${IMAGE}:${BUILD_TAG}"
docker push "${IMAGE}:${VERSION_TAG}"
docker push "${IMAGE}:latest"
- name: Publish Helm chart
if: gitea.ref == 'refs/heads/main' || gitea.event_name == 'schedule'
env:
PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "${PACKAGE_USERNAME}"
test -n "${PACKAGE_TOKEN}"
install -d .chart-packages
docker run --rm \
--volume "${PWD}:/source" \
--workdir /source \
alpine/helm:4.2.2@sha256:ee6fe3e96d9f8ea8dd1af9ecd7bbb3e233616a25f145392376f020fd2a51eb33 \
package helm/php-homepage \
--destination .chart-packages \
--version "${CHART_VERSION}" \
--app-version "${BUILD_TAG}"
chart_file=".chart-packages/php-homepage-${CHART_VERSION}.tgz"
test -f "${chart_file}"
curl --fail --silent --show-error \
--user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \
--request POST \
--upload-file "${chart_file}" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
+13 -4
View File
@@ -1,6 +1,6 @@
FROM archlinux
FROM archlinux:latest
RUN pacman -Syu --noconfirm apache php-apache php \
RUN pacman -Syu --noconfirm apache php-apache php php-gd php-pgsql \
&& pacman -Sc --noconfirm \
&& rm -v /var/log/pacman.log /var/lib/pacman/sync/*
@@ -12,6 +12,15 @@ RUN sed -i '/mod_mpm_event/ s/^/#/' /etc/httpd/conf/httpd.conf \
&& sed -i '/mod_rewrite/ s/^#//' /etc/httpd/conf/httpd.conf \
&& echo LoadModule php_module modules/libphp.so >> /etc/httpd/conf/httpd.conf \
&& echo AddHandler php-script .php >> /etc/httpd/conf/httpd.conf \
&& echo Include conf/extra/php_module.conf >> /etc/httpd/conf/httpd.conf
&& echo Include conf/extra/php_module.conf >> /etc/httpd/conf/httpd.conf \
&& install -d /etc/php/conf.d \
&& sed -i -e '/extension=exif/ s/^;//' \
-e '/extension=gd/ s/^;//' \
-e '/extension=intl/ s/^;//' \
-e '/extension=pgsql/ s/^;//' \
-e '/extension=pdo_pgsql/ s/^;//' \
/etc/php/php.ini
ENTRYPOINT httpd -DFOREGROUND
COPY opcache.ini /etc/php/conf.d/opcache.ini
ENTRYPOINT ["httpd", "-DFOREGROUND"]
+50 -1
View File
@@ -1,3 +1,52 @@
# php-apache
Simple docker image to run latest apache with latest php (provided by arch)
Arch Linux container image with Apache and PHP. It is the common runtime for
the PHP sites hosted at brunner.ninja, including WordPress and phpPgAdmin.
The image enables PostgreSQL and PDO PostgreSQL, GD for image thumbnails and
responsive sizes, EXIF for photo metadata, Intl for locale-aware operations,
and a production-sized PHP OPcache. Timestamp checks remain enabled so edits
made through the browser-based development container and WordPress updates on
the shared CephFS volume become visible without restarting Apache.
## Continuous integration
The Gitea Actions workflow builds and tests the image on every push. It also
rebuilds the rolling Arch Linux base every Monday. Push builds publish the
image to `registry.brunner.ninja/feedc0de/php-apache` with the Git commit SHA.
Builds of `main` additionally publish `latest` and an
`<apache-version>-<php-version>` tag. Scheduled and manually dispatched builds
do not overwrite commit tags; they refresh only the mutable main tags.
The workflow requires these repository secrets:
- `QUAY_USERNAME`: Quay robot-account username with write access to the image.
- `QUAY_TOKEN`: token for that robot account.
- `PACKAGE_USERNAME`: Gitea user that publishes packages for `feedc0de`.
- `PACKAGE_TOKEN`: Gitea token with package write access.
No downstream repository is triggered. PostgreSQL support is part of this
image, and derivative images such as phpPgAdmin rebuild on their own schedule.
## PHP homepage Helm chart
`helm/php-homepage` deploys this image with a CephFS-backed web root. Existing
sites should set `persistence.existingClaim`; in that mode the chart references
the PVC but deliberately does not render or own it. This keeps site content
independent of the Helm release and safe from `helm uninstall`.
The chart can create a new RWX PVC when `existingClaim` is empty. Such PVCs
carry Helm's `keep` resource policy by default as an additional safeguard for
website content.
Each successful `main` or scheduled image build also publishes a new chart to
the `feedc0de` Gitea Helm registry. Its `appVersion` is the unique image tag
built by that same workflow run. Site values do not override the image, so
updating the Helm repository and upgrading a release selects the image from the
new chart:
```sh
helm repo add brunner https://code.brunner.ninja/api/packages/feedc0de/helm
helm repo update brunner
helm upgrade --install example-site brunner/php-homepage -f values.yaml
```
+9
View File
@@ -0,0 +1,9 @@
apiVersion: v2
name: php-homepage
description: Serve a PHP website from a persistent shared web root
type: application
version: 0.1.0
appVersion: latest
home: https://code.brunner.ninja/feedc0de/php-apache
sources:
- https://code.brunner.ninja/feedc0de/php-apache
+61
View File
@@ -0,0 +1,61 @@
# PHP homepage chart
This chart runs the unified `php-apache` image with a web root mounted at
`/srv/http`. It is intended for sites whose source and mutable application data
live on CephFS so the same files are available to the runtime pod and the
browser-based development container.
Published chart packages set `appVersion` to the unique php-apache image tag
created in the same CI run. `image.tag` is empty by default and therefore uses
that `appVersion`. Keep site-specific values free of image overrides so a chart
upgrade also upgrades the runtime image.
Add and refresh the Gitea chart repository with:
```sh
helm repo add brunner https://code.brunner.ninja/api/packages/feedc0de/helm
helm repo update brunner
```
## Existing sites
Set the existing RWX claim in the site's values file:
```yaml
fullnameOverride: example-site
persistence:
existingClaim: example-site
```
When `existingClaim` is set, the chart emits no PersistentVolumeClaim. Helm
therefore neither owns nor deletes the claim. Deleting the release removes the
workload but leaves all website files intact.
The first installation can adopt a Deployment, Service, and Ingress previously
created by `kubectl apply`:
```sh
helm upgrade --install example-site brunner/php-homepage \
--namespace default \
--values /path/to/example-site/values.yaml \
--take-ownership \
--wait
```
Review `helm template` or a server-side dry-run before adoption. The release
name and `fullnameOverride` must match the existing resource names. The chart's
selector remains the legacy `app: <resource-name>` selector so Kubernetes does
not reject the Deployment because its selector is immutable.
## New sites
With `persistence.existingClaim` empty, the chart creates an RWX PVC using
`rook-cephfs`. The PVC has `helm.sh/resource-policy: keep` by default, so an
uninstall does not discard site content. Set `persistence.retain: false` only
when deleting the release should also delete its dynamically created claim.
Ingress, TLS, probes, scheduling, storage size, and the image tag are all
configurable through `values.yaml`. TCP probes are used by default because
individual websites may redirect `/`, require authentication, or select their
own HTTP status behavior.
+13
View File
@@ -0,0 +1,13 @@
{{- if .Values.persistence.existingClaim }}
Web content is mounted from existing PVC {{ .Values.persistence.existingClaim }}.
That PVC is referenced but is not managed by this Helm release.
{{- else if .Values.persistence.enabled }}
Web content is mounted from PVC {{ include "php-homepage.fullname" . }}.
{{- end }}
{{- if .Values.ingress.enabled }}
Website hosts:
{{- range .Values.ingress.hosts }}
{{ .host }}
{{- end }}
{{- end }}
+28
View File
@@ -0,0 +1,28 @@
{{- define "php-homepage.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "php-homepage.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "php-homepage.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "php-homepage.selectorLabels" -}}
app: {{ include "php-homepage.fullname" . }}
{{- end }}
{{- define "php-homepage.labels" -}}
helm.sh/chart: {{ include "php-homepage.chart" . }}
{{ include "php-homepage.selectorLabels" . }}
app.kubernetes.io/name: {{ include "php-homepage.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ default .Chart.AppVersion .Values.image.tag | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
@@ -0,0 +1,93 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "php-homepage.fullname" . }}
labels:
{{- include "php-homepage.labels" . | nindent 4 }}
{{- with .Values.deploymentAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
minReadySeconds: 5
revisionHistoryLimit: 3
strategy:
{{- toYaml .Values.strategy | nindent 4 }}
selector:
matchLabels:
{{- include "php-homepage.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "php-homepage.labels" . | nindent 8 }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ include "php-homepage.fullname" . }}
image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
{{- if .Values.probes.startup.enabled }}
startupProbe:
tcpSocket:
port: http
periodSeconds: {{ .Values.probes.startup.periodSeconds }}
timeoutSeconds: {{ .Values.probes.startup.timeoutSeconds }}
failureThreshold: {{ .Values.probes.startup.failureThreshold }}
{{- end }}
{{- if .Values.probes.readiness.enabled }}
readinessProbe:
tcpSocket:
port: http
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
{{- end }}
{{- if .Values.probes.liveness.enabled }}
livenessProbe:
tcpSocket:
port: http
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.persistence.enabled }}
volumeMounts:
- name: webroot
mountPath: {{ .Values.persistence.mountPath }}
{{- end }}
{{- if .Values.persistence.enabled }}
volumes:
- name: webroot
persistentVolumeClaim:
claimName: {{ default (include "php-homepage.fullname" .) .Values.persistence.existingClaim }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
+35
View File
@@ -0,0 +1,35 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "php-homepage.fullname" . }}
labels:
{{- include "php-homepage.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "php-homepage.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}
+21
View File
@@ -0,0 +1,21 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "php-homepage.fullname" . }}
labels:
{{- include "php-homepage.labels" . | nindent 4 }}
{{- if .Values.persistence.retain }}
annotations:
helm.sh/resource-policy: keep
{{- end }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "php-homepage.fullname" . }}
labels:
{{- include "php-homepage.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
protocol: TCP
targetPort: http
selector:
{{- include "php-homepage.selectorLabels" . | nindent 4 }}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["image", "persistence", "service", "ingress"],
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 },
"fullnameOverride": { "type": "string" },
"image": {
"type": "object",
"required": ["repository", "tag", "pullPolicy"],
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
}
},
"persistence": {
"type": "object",
"required": ["enabled", "existingClaim", "mountPath"],
"properties": {
"enabled": { "type": "boolean" },
"existingClaim": { "type": "string" },
"mountPath": { "type": "string", "minLength": 1 },
"retain": { "type": "boolean" }
}
},
"ingress": {
"type": "object",
"required": ["enabled"],
"properties": {
"enabled": { "type": "boolean" }
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
replicaCount: 1
image:
repository: registry.brunner.ninja/feedc0de/php-apache
# Empty means the chart's appVersion. Published charts set appVersion to
# the exact image build tag produced in the same CI run.
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets:
- name: quay-pull-secret
nameOverride: ""
fullnameOverride: ""
deploymentAnnotations: {}
podAnnotations: {}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
service:
type: ClusterIP
port: 80
persistence:
enabled: true
existingClaim: ""
mountPath: /srv/http
accessModes:
- ReadWriteMany
storageClass: rook-cephfs
size: 1Gi
retain: true
ingress:
enabled: false
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
hosts:
- host: example.invalid
paths:
- path: /
pathType: Prefix
tls: []
probes:
startup:
enabled: true
periodSeconds: 2
timeoutSeconds: 2
failureThreshold: 60
readiness:
enabled: true
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
liveness:
enabled: true
periodSeconds: 15
timeoutSeconds: 2
failureThreshold: 3
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
+12
View File
@@ -0,0 +1,12 @@
; Keep timestamp validation enabled because WordPress updates code on its PVC
; without necessarily restarting Apache.
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
opcache.save_comments=1
opcache.validate_permission=1
opcache.restrict_api=/srv/http