Follow

php-nginx-typo3

The TYPO3-tuned sibling: PHP, nginx and the extensions TYPO3 expects

The same idea as the static image, aimed at TYPO3: PHP-FPM with nginx plus the extensions, configuration and utilities TYPO3 installations need — including the helper scripts that made deployments boring.

Running a fleet of TYPO3 sites on one maintained image meant an upgrade to the runtime happened once, not once per project.


FactDetail
ContentsDockerfile, configuration, utility scripts
Use caseTYPO3 installations
StartedDecember 2016

What's in it

Extensions included

The PHP extensions TYPO3 requires, pinned in the image rather than installed at deploy time.

Fleet maintenance

One image to maintain for many sites.

In detail

What is actually inside it

Numbered nginx fragments

vhost.common.d/ is split into 05-security, 10-general, 15-log, 20-location-root, 25-location-fonts, 30-location-fpm, 35-location-php — the numbers are the load order, so a project can drop in a 37- fragment without editing anything.

Cron inside the image

config/cron/ ships the crontab TYPO3 installations expect (scheduler, cleanup) rather than leaving it to each deployment.

Ops access, by design

config/sshd/authorized_keys is part of the image and util/enable-debug.util.sh toggles debug mode — the container stayed inspectable instead of being a black box.

DetailValue
Base imagewebdevops/php-nginx:7.2
Includesnginx (upstream repo), APCu + APC-bc, Blackfire, cron, sshd
StartedDecember 2016

Technical notes

The decisions behind it

Why not just use a php-fpm Docker image?
Because the failures we kept fixing were configuration-shaped: nginx security fragments, PHP ini layering, APCu/APC compatibility and cron. Baking those in meant a new TYPO3 project started from a known-good runtime instead of an afternoon of rediscovery.
What did Blackfire give you?
Profiling on real deployments. In an agency setting the interesting regressions are usually content-shaped (one page, one extension, one query), so being able to profile a request inside the actual container — not a local reproduction — was the point.

Building the image, step by step

What the Dockerfile is actually doing

The Dockerfile is 96 lines, and the git log shows most of them were re-litigated at least once. Four separate commits in January 2018 are attempts at getting apcu_bc to load in the right order; two are attempts at nginx upgrade. Worth reading before trusting the image.
nginx from nginx.org, not Debian

The base image already contains nginx; the Dockerfile replaces it anyway. It appends deb http://nginx.org/packages/debian/ stretch nginx (with the matching deb-src line) to /etc/apt/sources.list, curls nginx_signing.key to /tmp and apt-key adds that file — no keyserver round-trip.

The install runs with -o Dpkg::Options::="--force-confnew" and --force-overwrite, so the upstream package overwrites /etc/nginx/nginx.conf instead of leaving a nginx.conf.dpkg-dist beside it. Without that flag the container would start with Debian's config and silently ignore this repo's.

Two lines earlier, dirmngr is installed explicitly and disable-ipv6 is written to ~/.gnupg/dirmngr.conf — not for nginx, but because a later step imports the MySQL signing key with gpg --recv-keys and dirmngr hung on IPv6 during the build.

APCu with the apc_* API bolted back on

TYPO3-era extensions call apc_fetch(), apc_store(), apc_add(). PHP 7 removed APC in favour of APCu, and APCu deliberately does not provide those function names. The image installs apcu_bc, which ships an apc.so that reimplements the old API on top of APCu:

pecl -d preferred_state=beta install apcu_bc

The preferred_state=beta is not stylistic — there was no stable release then (commit 8be829f). Load order is the whole trick. extension=apc.so is written to /opt/docker/etc/php/apcu-bc.ini, which is symlinked to /usr/local/etc/php/conf.d/**apcubc**.ini. PHP reads conf.d in filename order, and apcubc.ini sorts after the base image's APCu entry, so apcu.so loads before apc.so. The two commits before it (169b120, bf53981) are failed attempts at exactly this ordering.

Blackfire, sized to the running PHP

The Blackfire probe is a prebuilt .so per PHP version, so the Dockerfile derives the version rather than hard-coding it:

version=$(php -r "echo PHP_MAJOR_VERSION.PHP_MINOR_VERSION;")   # -> 72

It then downloads https://blackfire.io/api/v1/releases/probe/php/linux/amd64/$version, unpacks the tarball, moves blackfire-*.so into php -r "echo ini_get('extension_dir');" and chown root:roots it. The 97-blackfire.php.ini this enables does not carry a client key or server — only blackfire.agent_socket=tcp://blackfire.services.ruhmesmeile.local:8707, the agency's internal agent, not the public Blackfire service. The probe tarball is fetched without a checksum or signature check.

A "production" ini with development habits

config/php/99-docker.php.ini is headed "PRODUCTION configuration" and mostly is: display_errors = 0, expose_php = Off, memory_limit = 384M, max_execution_time = 900, max_input_vars = 5000, post_max_size/upload_max_filesize at 50M, date.timezone = Europe/Berlin.

Two lines do not fit the label. opcache.validate_timestamps = 1 with opcache.revalidate_freq = 2 means opcache stat()s every script every two seconds and picks up edits within a couple of seconds — handy across a deploy, wasted syscalls in steady state. And opcache.enable_cli = 1 optimises CLI processes too, which only matters because the scheduler runs as one.

mysql.default_host and mysqli.default_host both point at mysql — the docker-compose service name, resolved by the container network's DNS, not a real hostname.

The numbers in the fragment names

How nginx config is layered here

vhost.conf ends with a single include: include /opt/docker/etc/nginx/vhost.common.d/*.conf;. nginx expands that glob in sorted order, so the file names are the load order, and the two-digit prefixes are the interface. A project can drop in a 37- fragment without touching any file that already exists.

The Dockerfile takes ownership of that directory first — RUN rm -f /opt/docker/etc/nginx/vhost.common.d/* before copying vhost.common.d/ — so the base image's own fragments are gone and these seven files are the whole story.

Order matters most for the regex locations, which nginx evaluates in the order they appear in the merged config. 05-security therefore rejects /typo3conf/LocalConfiguration.sql before 35-location-php can treat it as a PHP candidate, and 30-location-fpm claims /status and /ping before the generic .php$ block could hand them to a script that does not exist.

FragmentWhat it does, and why the number
05-securityThree `deny all` regexes: files ending `.bak/.back/.yml/.sql/.ini/.log/.sh/.inc/.md/.swp/.dist` or `~`; anything under TYPO3 `private`, `tests` or `build`, dot-directories, and `secure_*` / `*_secure`; and any path segment starting with a dot. Deliberately redundant — `~*` already means case-insensitive even though the pattern also carries `(?i)`.
10-generalOne line: `client_max_body_size 50m`, deliberately equal to the PHP-side 50M in `99-docker.php.ini`.
15-lognginx access to `/docker.stdout`, errors to `/docker.stderr` — so the container logs like a container.
20-location-rootThe catch-all `location /`: a chain of `if` checks lets real files, directories, symlinks and the reserved TYPO3 paths (`/typo3`, `/typo3conf`, `/typo3temp`, `/fileadmin`, `/uploads`, `/index.php`, `/favicon.ico`) through, then rewrites everything else to `/index.php`. "`if` is evil" inside a `location` — deliberate here because TYPO3 routing depends on it.
25-location-fontsAdds `Access-Control-Allow-Origin *` for `eot/otf/ttf/woff/woff2`, i.e. fonts pulled cross-origin by the frontend build.
30-location-fpmExposes FPM `pm.status_path = /status` and `ping.path = /ping` through `fastcgi_pass`, with `access_log off`. Used as the container health probe.
35-location-phpThe `\.php$` handler: `try_files $uri $uri/ /index.php?$args`, `fastcgi_split_path_info`, and 5-minute read/send timeouts.
Two subtleties in 05-security matter before editing it. The second regex uses (?!css_secure)[^/]*_secure, a negative lookahead that spares the generated css_secure files from the blanket _secure ban; and the typo3(conf)?(/.*|)/(private|tests|build) branch matches both /typo3/... and /typo3conf/.... Separately, vhost.conf itself defines the cache map: expires resolves to epoch for text/html (HTML is never cached), max for CSS, JS, fonts and images — that map is what makes the bare expires $expires in the server block do anything.

The runtime, in numbers

What the pool and ini actually set

The pool file config/php/application.conf and the ini are where the runtime is actually sized. These are the real values, not defaults:
30
FPM children
pm = dynamic with pm.max_children = 30, starting at 10 and holding 5–10 spare (pm.start_servers = 10, min/max_spare_servers = 5/10). Sized for a handful of TYPO3 sites per host, not a shared platform. request_terminate_timeout is commented out, so FPM itself will never kill a runaway worker — only max_execution_time = 900 does.
900
seconds max_execution_time
The PHP-side ceiling, with max_input_time = 300. Set for TYPO3 backend work — extension installs, cache warmups and imports — that would otherwise hit PHP's default 30s.
384
MB memory_limit
TYPO3's backend and larger content operations are memory-hungry; this is the value the pool advertises. rlimit_core = unlimited is also set, so a crashing worker can still dump core for the strace/gdb workflow below.
50
MB upload and POST
client_max_body_size 50m in nginx equals post_max_size and upload_max_filesize = 50M in PHP. A mismatch between those three is the classic silent fileadmin upload failure.
5
minute FastCGI timeouts
fastcgi_read_timeout and fastcgi_send_timeout are both 5m, against an nginx default of 60s. pm.status_path/ping.path are enabled on the same socket, and FPM's own slowlog goes to /docker.stderr with request_slowlog_timeout = 10s.

Pinning a TYPO3 era

What the frozen versions cost