One of a pair of base images we ran most of our sites on: PHP with nginx, kept deliberately small, with the nginx and PHP configuration versioned next to the Dockerfile rather than baked in somewhere else.
The point was consistency: every project started from the same known-good image instead of a bespoke Dockerfile per site.
| Fact | Detail |
|---|---|
| Contents | Dockerfile + nginx/PHP configuration |
| Use case | Static and plain-PHP sites |
| Started | September 2017 |
What's in it
The server configuration is reviewable and versioned with the image.
Removing the per-project snowflake was the feature.
In detail
What is actually inside it
FROM ruhmesmeile/php-nginx-typo3:7.2, then only the PHP ini, the FPM pool and one nginx fragment are replaced — so both images stay on the same base and fixes propagate.
EXPOSE 3003: if a static site ships a small local API, that is where it lives. Small, but it is the sort of thing that saves a conversation per project.
| Detail | Value |
|---|---|
| Base image | ruhmesmeile/php-nginx-typo3:7.2 |
| Tuned for | static pages: smaller FPM pool, less RAM |
| Started | September 2017 |
Technical notes
The decisions behind it
Why start from the TYPO3 image?
Tuned down for static pages
Where the sub-image actually differs
The parent runs a dynamic FPM pool sized for TYPO3: up to 30 workers (pm.max_children = 30), 10 forked at boot (pm.start_servers = 10) and a spare pool held between 5 and 10. The static image rewrites that one block to pm = static, pm.max_children = 4, and comments the three dynamic-only knobs back out.
static means exactly four PHP workers exist from container start and never grow. Four concurrent PHP requests is the hard ceiling — which is the point. On a site that mostly hands back pre-built HTML, thirty resident workers are pure overhead on a host that runs many of these containers, and a real PHP load spike is a problem to notice rather than silently absorb.
| Setting | php-nginx-typo3 | php-nginx-static |
|---|---|---|
| Process manager (`pm`) | dynamic | static |
| `pm.max_children` | 30 | 4 |
| `memory_limit` | 384M | 128M |
| Zend OPcache | on, 192 MB | off |
| `post_max_size` / `upload_max_filesize` | 50M / 50M | 25M / 25M |
| FPM listen socket | php7.2-fpm.sock | php7.1-fpm.sock |
The PHP ini diff is smaller than it looks: five values change and one block disappears. The parent enables OPcache with memory_consumption = 192, a 16 MB interned-strings buffer, room for 10 000 files, enable_cli = 1 and timestamp validation every 2 s; the static image replaces all of it with a single opcache.enable = 0.
That drops a ~192 MB shared-memory segment the container no longer claims — the number that matters when one host runs a long tail of small sites. With almost no PHP executing at request time there is nothing worth keeping warm, so the bytecode cache is the first thing to go.
One file removed, everything else kept
The shape of the override
Strip the comments from the Dockerfile and four lines remain: copy a php.ini, copy an FPM pool, rm -f one nginx fragment and copy a replacement into the same slot, then EXPOSE 3003. Everything else — the rest of the nginx fragment set, the cron job, the PHP extensions, the SSH setup — is inherited untouched from ruhmesmeile/php-nginx-typo3:7.2.
The rm before the COPY is the tell. The image does not add a new security fragment; it deletes the parent's 05-security.conf and drops its own in its place, because the whole directory is included as-is and a second file would only append a second set of location blocks.
What did the replacement security fragment actually change?
The parent's first block denies a list of suffixes — .bak, .yml, .sql, .ini, .log, .sh, .inc, .md, .swp, .dist, ~ — and, inside the same pattern, (^|/)changelog(.[a-zA-Z]+)?$. The static version keeps the suffix list and drops the changelog alternative, so a changelog path is served instead of answered with 403. The other two blocks (the TYPO3 private/tests/build and *_secure paths, and dotfiles) are copied verbatim.
Worth noting what stays blocked: .md is still in the suffix list, so CHANGELOG.md is still denied by this very fragment. The override only helps an extension-less changelog, a directory of that name, or a rendered changelog.html / .txt.
Was that a one-line change?
ba2fd4b1 ("Allow changlog.* on Nginx") added the fragment but still carried the clause it was named after, so it changed nothing functional; d7052e8d ("Do it for real…") removed the clause but left an unbalanced group in the location pattern; 0f935412 ("Try again like this") finally produced the file that ships.And the listen socket?
listen = /var/run/php/php7.1-fpm.sock, while the nginx fragment it inherits (35-location-php.conf) passes FastCGI to unix:/var/run/php/php7.2-fpm.sock. The FROM line moved to the 7.2 base in June 2019 (58146e64); the pool's socket path was never retargeted. On a static site this is almost invisible — requests rarely reach PHP at all — which is exactly why a mismatch like it can sit in a base image unnoticed.The parent installs /etc/cron.d/typo3 and a crond supervisor program for the TYPO3 scheduler. There is no override, so a static container runs a cron entry nothing on the site asked for.
The parent bakes in the Blackfire probe .so and extension=apc.so (via apcu_bc) for TYPO3's legacy APC calls. Both survive into an image that serves HTML.
The MySQL client package and mysql.default_host = mysql / mysqli.default_host = mysql come along — the DB path is wired to the Compose service name even where no DB is used.
The parent enables PermitRootLogin yes, installs sshd and sudo, and copies an authorized_keys into /root and /home/docker. With no override, the static image carries the same access path.
Every vhost.common.d file except the replaced one is inherited, including 20-location-root.conf and its front-controller rewrite .* /index.php last.
Port 3003, end to end
A convention, not a service
Why does a static image expose 3003?
EXPOSE 3003 only declares where a site's own local API would listen, and the base image's service manager picks the port up from there. That was a deliberate narrowing: 58146e64 first added docker-service-enable node, then 320cecab removed it with the note that it "should not be activated by default", leaving just the exposed port; the leftover provisioning scripts were deleted in d2592c70, the last commit on the repository.So where does an API actually start?
/app with WEB_DOCUMENT_ROOT=/app/web and WEB_DOCUMENT_INDEX=index.html; if the build contains web/server it is moved to /app/server. Once the container is up, the unit runs docker exec … docker-service-enable node inside it and starts a sidecar: alpine/socat publishing TCP 3003, registered with the reverse proxy as %i.api.ruhmesmeile.tech via VIRTUAL_PORT=3003. The container names its own port SERVICE_3003_NAME=static-tech-api.Every tier, or just some?
.tech staging tier. The static-dev@, static-vision@ and static-review@ units pull the same image and mount the same document root but never touch server/ and never enable Node; SERVICE_3003_NAME appears on static-tech@ alone.Links
Where to find this one
