From TYPO3 container hosting to a cluster
Challenges, architecture and security concepts on the way to a highly available TYPO3 system.
What started as simple Docker container hosting with a single TYPO3 instance grows, in this series, step by step into a production-ready cluster solution. In this part we prepare the container for production use — with a focus on security, stability and immutability.
Overview
This series covers the full path from simple container hosting to a production-ready cluster:
- Part 1 — Fundamentals: containers and TYPO3
- Part 2 — Dockerfile and image build
- Part 3 — PHP-FPM configuration
- Part 4 — Web server integration (Caddy/Nginx)
- Part 5 — Database and persistence
- Part 6 — Caching with Redis/Valkey
- Part 7 — Secrets management
- Part 8 — CI/CD pipeline
- Part 9 — Monitoring and logging
- Part 10 — Cluster operations with Kubernetes
- Part 11 — Production hardening
Container ready for take-off
The central principle when moving into production is: security and stability come first. A container image that works in development is far from production-ready.
The most important rule here is immutability: a container image must not change after release. What was built and tested is exactly what runs in production. No after-the-fact changes, no manual intervention, no surprises.
DevOps fundamentals
DevOps describes the cooperation between development (Dev) and operations (Ops) with the goal of delivering software faster, more reliably and in an automated way. It is not a role, but a culture and methodology that breaks down silos between teams.
SRE (Site Reliability Engineering) is Google's implementation of DevOps principles. SRE treats operations as a software engineering problem and defines measurable goals for availability (SLOs), automated incident response and systematic capacity planning.
Minimalistic systems or not?
For production containers, clear principles apply:
- Reduce to the essentials: the less there is in the container, the smaller the attack surface. No build tooling, no debug tools, no unnecessary packages.
- Isolation: a container does exactly one job. PHP-FPM runs in its own container, the web server in another.
- No root: containers run as an unprivileged user. Root access in production is a security risk.
- Multi-stage builds: build dependencies stay in the build stage and don't end up in the final image.
Operating system comparison for containers
The choice of base image directly affects security and maintainability:
RHEL / UBI (Red Hat Universal Base Image):
- Enterprises trust Red Hat, but updates arrive slowly
- Images are large (200+ MB for the minimal variant)
- Often oversized for container environments
Alpine Linux:
- Extremely small (5 MB base image), widely used
- Uses musl instead of glibc — this can lead to PHP compatibility issues
- Known issues with umlauts and locale handling (relevant for German-language TYPO3 sites)
- Good for simple services, to be approached with caution for PHP applications
Wolfi OS (recommended):
- Developed by Chainguard, designed specifically for containers
- Goal: zero known CVEs — regular security updates, no outdated packages
- Uses glibc instead of musl — full PHP compatibility, no umlaut problems
- Minimal images, comparable to Alpine in size
- Ideal for production-ready PHP containers
Immutability
Immutability is the key to stable and secure containers. The principle:
- Build once, run everywhere: the same image runs in staging and production.
- No changes at runtime: no
apt-get installin a running container, no manual file copying. - Configuration via environment variables and secrets: anything that differs between environments comes in from outside.
- Reproducible: any image can be rebuilt identically at any time (deterministic builds).
If a problem occurs, you don't repair the running container — you build a new image and deploy it. That eliminates configuration drift and makes rollbacks trivial.
Preparing the configuration files
Before we create the Dockerfile, we prepare the PHP configuration files. They are copied into the image at build time.
php.ini
The php.ini is optimised for production:
[PHP]
; Error display off — errors belong in the log, not on the screen
display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
log_errors = On
error_log = /proc/self/fd/2
; Security-relevant settings
expose_php = Off
allow_url_fopen = On
allow_url_include = Off
; Performance settings
max_execution_time = 240
max_input_time = 60
max_input_vars = 1500
memory_limit = 512M
post_max_size = 100M
upload_max_filesize = 100M
; Session configuration
session.cookie_httponly = On
session.cookie_secure = On
session.cookie_samesite = Strict
session.use_strict_mode = On
; OPcache for production
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.jit = tracing
opcache.jit_buffer_size = 128M
; Time zone
date.timezone = Europe/Berlin
; Realpath cache (important for Symfony/TYPO3)
realpath_cache_size = 4096K
realpath_cache_ttl = 600
Important notes on the settings:
opcache.validate_timestamps = 0— in production the code does not change (immutability), so OPcache doesn't need to check whether files have changed. That saves I/O operations.error_log = /proc/self/fd/2— errors are written to stderr, where Docker picks them up as container logs.expose_php = Off— prevents the PHP version header from appearing in responses.session.cookie_secure = On— cookies are only sent over HTTPS.
www.conf (PHP-FPM pool configuration)
The pool configuration controls how PHP-FPM manages worker processes:
[www]
user = www-data
group = www-data
listen = 9000
; Process management
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500
; Status and ping for health checks
pm.status_path = /status
ping.path = /ping
ping.response = pong
; Logging
access.log = /proc/self/fd/2
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s
; Security
security.limit_extensions = .php
; Pass through environment variables
clear_env = no
Key settings explained:
pm = dynamic— worker processes are started and stopped on demand. The best choice for most applications.pm.max_requests = 500— a worker is recycled after 500 requests. That prevents memory leaks.clear_env = no— environment variables are passed through to PHP processes (important for container configuration).request_slowlog_timeout = 5s— requests longer than 5 seconds are logged in the slowlog.
php-fpm.conf
The global PHP-FPM configuration:
[global]
error_log = /proc/self/fd/2
log_limit = 1000000
log_limit is set to 1,000,000 characters so that even long stack traces appear fully in the log (the default is only 1024 characters).
Building the Dockerfile
The Dockerfile uses Wolfi OS as its base and follows the multi-stage build principle:
# Stage 1: build
FROM cgr.dev/chainguard/wolfi-base AS build
# Install PHP and build dependencies
RUN apk add --no-cache \
php-8.3 \
php-8.3-fpm \
php-8.3-pdo \
php-8.3-pdo_mysql \
php-8.3-gd \
php-8.3-intl \
php-8.3-opcache \
php-8.3-zip \
php-8.3-xml \
php-8.3-mbstring \
php-8.3-curl \
php-8.3-tokenizer \
php-8.3-fileinfo \
php-8.3-session \
php-8.3-ctype \
composer
# Install Composer dependencies
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
COPY . .
RUN composer dump-autoload --optimize
# Stage 2: production
FROM cgr.dev/chainguard/wolfi-base AS production
RUN apk add --no-cache \
php-8.3 \
php-8.3-fpm \
php-8.3-pdo \
php-8.3-pdo_mysql \
php-8.3-gd \
php-8.3-intl \
php-8.3-opcache \
php-8.3-zip \
php-8.3-xml \
php-8.3-mbstring \
php-8.3-curl \
php-8.3-tokenizer \
php-8.3-fileinfo \
php-8.3-session \
php-8.3-ctype
# Copy configuration files
COPY docker/php/php.ini /etc/php/8.3/php.ini
COPY docker/php/www.conf /etc/php/8.3/php-fpm.d/www.conf
COPY docker/php/php-fpm.conf /etc/php/8.3/php-fpm.conf
# Copy application from build stage
COPY --from=build /app /var/www/html
# Unprivileged user
RUN adduser -D -u 1000 www-data
USER www-data
WORKDIR /var/www/html
EXPOSE 9000
CMD ["php-fpm8.3", "-F"]
Important points about the Dockerfile:
- Multi-stage build: Composer and build tools are only present in the build stage. The final image contains only the runtime dependencies.
- Unprivileged user: the container runs as
www-data, not as root. - No entrypoint script: PHP-FPM is started directly. Configuration comes via environment variables and secrets.
Building the image
The image is built with a simple docker build:
docker build -t typo3-app:latest -f Dockerfile .
For multi-architecture support (e.g. ARM64 for AWS Graviton or Apple Silicon) you can use docker buildx:
docker buildx build --platform linux/amd64,linux/arm64 -t typo3-app:latest .
After the build, the image should be tagged with RepoDigests. A RepoDigest is a SHA256 hash that uniquely identifies the image — independent of the tag. That ensures exactly this image gets deployed:
docker inspect --format='{{index .RepoDigests 0}}' typo3-app:latest
In production deployments you should always reference the digest rather than a tag, since tags can be overwritten while digests are immutable.