docker-compose.yml: the containers
Five services, shared variables and volumes, health checks and the start order.
Overview
docker-compose.yml describes which containers run and how they relate. Docker Compose creates the containers, a shared network and the volumes from it. Within the network, containers reach each other by service name, such as db or php.
All values that differ per installation come as ${VARIABLE} from the .env file in the project directory. The file itself therefore stays the same for every installation.
Project name
name: typo3The project name is the prefix for containers, network and volumes, for example typo3-php-1 and typo3_db-data. The environment variable COMPOSE_PROJECT_NAME overrides it. This lets a second, completely separate instance run next to the first, for example to test a restore:
COMPOSE_PROJECT_NAME=typo3test HTTP_PORT=8089 docker compose up -d
Shared environment variables
x-typo3-env: &typo3-env
TYPO3_CONTEXT: ${TYPO3_CONTEXT:-Production}
TYPO3_DB_HOST: db
TYPO3_DB_PORT: 3306
TYPO3_DB_DBNAME: ${DB_NAME:-typo3}
TYPO3_DB_USERNAME: ${DB_USER:-typo3}
TYPO3_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD fehlt in .env}
TYPO3_TRUSTED_HOSTS_PATTERN: ${TYPO3_TRUSTED_HOSTS_PATTERN:-typo3-v14\.localhost(:\d+)?}
TYPO3_REVERSE_PROXY_IP: ${TYPO3_REVERSE_PROXY_IP:-}
TYPO3_SMTP_SERVER: ${TYPO3_SMTP_SERVER:-}
…
TYPO3_OIDC_ISSUER: ${TYPO3_OIDC_ISSUER:-}
TYPO3_OIDC_CLIENT_ID: ${TYPO3_OIDC_CLIENT_ID:-}
TYPO3_OIDC_CLIENT_SECRET: ${TYPO3_OIDC_CLIENT_SECRET:-}
TYPO3_OIDC_SCOPES: ${TYPO3_OIDC_SCOPES:-openid,profile,email}
TYPO3_OIDC_DEBUG: ${TYPO3_OIDC_DEBUG:-0}
ELASTICSEARCH_URL: http://elasticsearch:9200
ELASTICSEARCH_USERNAME: elastic
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:?ELASTICSEARCH_PASSWORD fehlt in .env}
ELASTICSEARCH_INDEX_PREFIX: ${ELASTICSEARCH_INDEX_PREFIX:-typo3}php and scheduler need the same variables. To define them only once, the file uses two techniques:
- Extension fields: Compose ignores keys starting with
x-. They only serve as storage. - YAML anchors:
&typo3-envnames the block, and*typo3-envinserts it elsewhere.<<: *typo3-envmerges it into a mapping that gets additional entries of its own.
The variable notations:
| Expression | Meaning |
|---|---|
${VAR:-value} | value from .env, otherwise value as the default |
${VAR:-} | value from .env, otherwise empty, for example to switch off OIDC or SMTP |
${VAR:?text} | required value. If it is missing, Compose aborts with text instead of starting with an empty password. |
TYPO3_DB_HOST: db is the database’s service name in the Docker network, just like elasticsearch in ELASTICSEARCH_URL. additional.php evaluates all of these variables.
Shared volumes
x-typo3-volumes: &typo3-volumes
- typo3-config:/var/www/html/config
- typo3-fileadmin:/var/www/html/public/fileadmin
- typo3-temp:/var/www/html/public/typo3temp
- typo3-var:/var/www/html/varFollowing the same pattern, this is the list of volumes for php and scheduler. Each line mounts a named volume over a folder in the container. The content persists when containers are recreated:
config:settings.phpfrom the installation and the site configurationpublic/fileadmin: uploaded filespublic/typo3temp: generated images and assetsvar: caches, logs, language packs
On the very first start, a volume is empty. Docker then fills it with the content the image has at that location.
Service db
db:
image: mariadb:11.8
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME:-typo3}
MARIADB_USER: ${DB_USER:-typo3}
MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD fehlt in .env}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD fehlt in .env}
volumes:
- db-data:/var/lib/mysql
- ./config/mariadb/typo3.cnf:/etc/mysql/conf.d/typo3.cnf:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s- Image instead of build: the official MariaDB image is used unchanged.
restart: unless-stoppedrestarts the container after crashes and after a reboot of the machine, unless you stopped it deliberately. This applies to all five services.- Environment: the
MARIADB_*variables create the database, user and passwords. They only take effect on the first start with an empty volume. Changing a password in.envlater does not change the existing database. - Volumes:
db-dataholds the database files. The second line is a bind mount: it mounts the project filetypo3.cnfdirectly, and:romeans read-only. It sets utf8mb4 and InnoDB options. - Health check: the bundled
healthcheck.shchecks that connections are possible and InnoDB is ready.start_periodgives the database 30 seconds to start up before failures count.
Service elasticsearch
elasticsearch:
image: elasticsearch:9.5.3
restart: unless-stopped
environment:
discovery.type: single-node
cluster.name: typo3
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "false"
xpack.security.transport.ssl.enabled: "false"
ELASTIC_PASSWORD: ${ELASTICSEARCH_PASSWORD:?ELASTICSEARCH_PASSWORD fehlt in .env}
ingest.geoip.downloader.enabled: "false"
xpack.ml.enabled: "false"
ES_JAVA_OPTS: ${ELASTICSEARCH_JAVA_OPTS:--Xms512m -Xmx512m}
volumes:
- es-data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS -u \"elastic:$${ELASTIC_PASSWORD}\" 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' >/dev/null"]
interval: 10s
timeout: 10s
retries: 12
start_period: 60sElasticsearch holds the website’s search index. TYPO3 writes pages and content into it, and the search page queries it.
- Image: the official image with a fixed version. Elasticsearch reads settings whose names contain a dot, such as
discovery.type, directly from the environment. discovery.type: single-node: a single node without cluster discovery. TYPO3 therefore creates the indices without replicas; otherwise their status would stay “yellow” forever.- Security: authentication is on, user
elastic, password fromELASTICSEARCH_PASSWORD. TLS is off because the service publishes no port: it is only reachable on the internal Docker network, forphpandscheduler. - Privacy:
ingest.geoip.downloader.enabled: "false"stops Elasticsearch from regularly downloading GeoIP databases from Elastic. Machine learning is not needed and is switched off as well. - Memory:
ES_JAVA_OPTSsets the Java heap, 512 MB by default. The container needs about twice as much. - Volume:
es-dataholds the index. It can be rebuilt from the database at any time and therefore does not belong in the backup. - Health check:
curlqueries the cluster status and waits up to five seconds for at least “yellow”.$$stops Compose from substituting the variable itself, so the shell inside the container inserts the password.
Service php
php:
build:
context: .
target: app
image: typo3-appliance-php:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
elasticsearch:
condition: service_started
environment:
<<: *typo3-env
TYPO3_SETUP_ADMIN_USERNAME: ${TYPO3_ADMIN_USERNAME:-admin}
TYPO3_SETUP_ADMIN_PASSWORD: ${TYPO3_ADMIN_PASSWORD:?TYPO3_ADMIN_PASSWORD fehlt in .env}
TYPO3_SETUP_ADMIN_EMAIL: ${TYPO3_ADMIN_EMAIL:-}
TYPO3_PROJECT_NAME: ${TYPO3_PROJECT_NAME:-TYPO3}
TYPO3_SITE_URL: ${TYPO3_SITE_URL:-http://typo3-v14.localhost:8088/}
volumes: *typo3-volumes
healthcheck:
test: ["CMD", "typo3-healthcheck"]
interval: 10s
timeout: 5s
retries: 5
start_period: 120sbuildbuilds theDockerfilein the project directory, specifically the stageapp.imagegives the result a fixed name. Theschedulerand the backup scripts use it.depends_onwithservice_healthy: the container only starts once the database health check succeeds. Merely “started” is not enough. Forelasticsearch, however,service_startedsuffices: TYPO3 should run even without search. The entrypoint waits at most about a minute for Elasticsearch and then syncs the search index.- Environment: all shared variables plus the values for the first installation. The entrypoint only evaluates
TYPO3_SETUP_*, the project name and the site URL as long as there is nosettings.phpyet. - Health check:
typo3-healthcheckfrom the image.start_period: 120sgives the first installation enough time, including the language pack download.
The container publishes no port. PHP-FPM is only reachable for nginx on the internal network.
Service web
web:
build:
context: .
target: web
image: typo3-appliance-web:latest
restart: unless-stopped
depends_on:
php:
condition: service_healthy
ports:
- "${HTTP_PORT:-8088}:80"
volumes:
- typo3-fileadmin:/var/www/html/public/fileadmin:ro
- typo3-temp:/var/www/html/public/typo3temp:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/fpm-ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s- Build: the same Dockerfile, this time the stage
web. portspublishes the container’s port 80 on the machine, by default as port 8088.HTTP_PORTin.envchanges it. On a server behind a reverse proxy,127.0.0.1:8088:80is recommended so that the port is only reachable locally.- Read-only volumes: nginx serves uploads and generated images directly and therefore gets the same volumes as
php.:roprevents nginx from changing anything there. - Health check:
wgetrequests/fpm-ping. nginx forwards the request to the PHP-FPM ping endpoint. A successful check therefore proves the whole chain nginx → PHP-FPM. The endpoint only accepts requests from 127.0.0.1, so it is not reachable from outside.
Service scheduler
scheduler:
image: typo3-appliance-php:latest
restart: unless-stopped
depends_on:
php:
condition: service_healthy
user: www-data
entrypoint:
- sh
- -c
- |
i=0
while true; do
vendor/bin/typo3 scheduler:run
if [ $$((i % 15)) -eq 0 ]; then
out=$$(vendor/bin/typo3 search:index 2>&1) || echo "$$out"
fi
i=$$((i + 1))
sleep 60
done
environment: *typo3-env
volumes: *typo3-volumesThe TYPO3 scheduler handles recurring tasks such as clean-ups, index updates or reports. It needs a clock. Instead of a cron service, a dedicated container provides it:
- It uses the finished image of
phpand builds nothing itself. entrypointreplaces the TYPO3 entrypoint with an endless loop:scheduler:run, then wait 60 seconds. Only thephpcontainer handles installation and schema updates.- Every 15th iteration, that is every 15 minutes, also syncs the search index with
search:index. This catches up on changes that did not arrive when saving, for example while Elasticsearch was restarting. Output only appears in the container log if there is an error. - The script is a YAML list item written with
|(multi-line text).$$writes a literal$, so that Compose does not evaluate$((…))and$outas its own variables. user: www-dataruns the tasks as the same user as PHP-FPM, so generated files belong to the right user.- The same variables and volumes as
php, so both work on the same data.
Volumes and start order
volumes:
db-data:
es-data:
typo3-config:
typo3-fileadmin:
typo3-temp:
typo3-var:The volumes section declares the named volumes. Without further options, Docker manages them itself. They survive docker compose down and rebuilds. Only docker compose down -v deletes them.
The depends_on conditions produce the start order. elasticsearch starts in parallel with the database. php only waits for its container to be running, not for its health check:
db ──healthy──▶ php ──healthy──▶ web
elasticsearch ──started──▶ php └─healthy──▶ schedulerThis way nginx never receives requests before TYPO3 is installed, and the scheduler never starts on a half-configured database.