One image serves two PHP applications behind URI routing, with a static-file
share in front of a front controller. It also shows the per-application PHP
knobs: options.admin / options.user, a per-app php.ini via options.file,
and the Unit environment block.
docker compose up --build # from this directoryThen:
curl http://localhost:8080/ # site front controller (diagnostic page)
curl http://localhost:8080/assets/style.css # static file, served directly by `share`
curl http://localhost:8080/admin/ # the second app: different memory_limitThe listener passes to a route (config.json, routes/main) with
two steps, evaluated in order:
matchuri = /admin/*→pass applications/admin. Anything under/admin/goes to the second app.- no match →
share /www/public$uriwithfallback→applications/site. Everything else tries to serve a real file from/www/public; if none exists, it falls back to thesitefront controller. This is the classic split: static assets come off disk, dynamic paths go to PHP.
/www/public/assets/style.css exists on disk, so GET /assets/style.css is
served by share and never reaches PHP — proof the static path works. GET /
has no matching file, so it falls back to site/index.php.
sitesetsmemory_limitanddisplay_errors=0viaoptions.admin(non-overridable by the app),date.timezoneviaoptions.user(overridable), andAPP_ENV/APP_GREETINGvia theenvironmentblock. The page reads them back withini_get/getenv.adminloads a per-appoptions.file(/www/admin/php.ini) with a differentmemory_limit. SoGET /reports256MandGET /admin/reports512Mfrom the same image — independent PHP config per application.
The site page prints the PHP version, loaded extension list, config values, and
environment on purpose, to demonstrate the image's capabilities. That makes it
a stack-fingerprinting surface: in production, remove such an endpoint or put it
behind authentication, and never echo getenv() of arbitrary environment into a
response — real deployments keep secrets in env. It does not call phpinfo();
it checks specific extensions (apcu, redis, gd, intl, mbstring) the base
image ships.
config.json— one listener, a two-step route (URI match + share/fallback), and two PHP applications with distinctoptionsandenvironment.- Hardening — the same
cap_drop: [ALL]+cap_add: [SETUID, SETGID]+no-new-privilegesblock asbasic/, so the workers run unprivileged.
docker compose up --build -d
curl -s http://localhost:8080/ | grep 'memory_limit = 256M' # site options.admin
curl -s http://localhost:8080/admin/ | grep 'memory_limit = 512M' # admin options.file
curl -sI http://localhost:8080/assets/style.css | head -n1 # 200, served by share
docker compose down -v