Configuration files

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: typo3

The 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-env names the block, and *typo3-env inserts it elsewhere. <<: *typo3-env merges it into a mapping that gets additional entries of its own.

The variable notations:

ExpressionMeaning
${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/var

Following 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.php from the installation and the site configuration
  • public/fileadmin: uploaded files
  • public/typo3temp: generated images and assets
  • var: 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-stopped restarts 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 .env later does not change the existing database.
  • Volumes: db-data holds the database files. The second line is a bind mount: it mounts the project file typo3.cnf directly, and :ro means read-only. It sets utf8mb4 and InnoDB options.
  • Health check: the bundled healthcheck.sh checks that connections are possible and InnoDB is ready. start_period gives 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: 60s

Elasticsearch 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 from ELASTICSEARCH_PASSWORD. TLS is off because the service publishes no port: it is only reachable on the internal Docker network, for php and scheduler.
  • 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_OPTS sets the Java heap, 512 MB by default. The container needs about twice as much.
  • Volume: es-data holds the index. It can be rebuilt from the database at any time and therefore does not belong in the backup.
  • Health check: curl queries 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: 120s
  • build builds the Dockerfile in the project directory, specifically the stage app. image gives the result a fixed name. The scheduler and the backup scripts use it.
  • depends_on with service_healthy: the container only starts once the database health check succeeds. Merely “started” is not enough. For elasticsearch, however, service_started suffices: 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 no settings.php yet.
  • Health check: typo3-healthcheck from the image. start_period: 120s gives 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.
  • ports publishes the container’s port 80 on the machine, by default as port 8088. HTTP_PORT in .env changes it. On a server behind a reverse proxy, 127.0.0.1:8088:80 is 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. :ro prevents nginx from changing anything there.
  • Health check: wget requests /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-volumes

The 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 php and builds nothing itself.
  • entrypoint replaces the TYPO3 entrypoint with an endless loop: scheduler:run, then wait 60 seconds. Only the php container 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 $out as its own variables.
  • user: www-data runs 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──▶  scheduler

This way nginx never receives requests before TYPO3 is installed, and the scheduler never starts on a half-configured database.