Magento in Containers

Build the application into an image, keep media and env.php outside it, and run web, cron and consumers from the same build.

Syntaxdocker build -t store/app:1.42 . && docker push store/app:1.42

A Magento image should hold the application exactly as the build produced it: vendor/, generated/ (the classes setup:di:compile writes) and pub/static (the assets setup:static-content:deploy writes). Nothing inside it should change while it runs.

A two-stage build

The first stage installs dependencies and compiles, in this order: composer install --no-dev, bin/magento setup:di:compile, composer dump-autoload -o, bin/magento setup:static-content:deploy. Both Magento commands read app/etc/config.php, the committed file listing modules, themes and locales, and neither connects to the database. That is what makes them safe in a build with no services running. The second stage copies the result into a clean runtime image.

What stays outside

app/etc/env.php holds hosts and passwords, so it is mounted or written at start by bin/magento setup:config:set. pub/media is written by admins at runtime, so it is a volume or object storage. Logs go to a volume or to stdout. Never mount a volume over pub/static or generated/.

Three roles, one image

The web container runs php-fpm. The cron container runs Magento's scheduler and there is exactly one of it, because two schedulers race over the cron_schedule table. Each consumer container runs bin/magento queue:consumers:start <name> --max-messages=1000; a consumer is a long-running PHP process that reads one queue, and it exits on purpose after that many messages so the restart policy gives it a fresh process. Set cron_consumers_runner/cron_run to false in env.php, or cron starts a second copy of every consumer.

Example

Example · dockerfile
# Dockerfile (project root). Build: docker build -t store/app:1.42 .
FROM php:8.4-fpm-alpine AS build

COPY --from=mlocati/php-extension-installer:2 \
     /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl pdo_mysql soap sockets \
    sodium xsl zip opcache
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

WORKDIR /app
COPY composer.json composer.lock auth.json ./
RUN composer install --no-dev --no-interaction --prefer-dist

COPY . .
RUN bin/magento setup:di:compile \
 && composer dump-autoload -o --no-dev \
 && bin/magento setup:static-content:deploy -f en_US de_DE \
      --jobs=4 --content-version=1.42 \
 && rm -rf var/cache/* var/page_cache/* var/view_preprocessed/* auth.json

FROM php:8.4-fpm-alpine
COPY --from=mlocati/php-extension-installer:2 \
     /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl pdo_mysql soap sockets \
    sodium xsl zip opcache
COPY docker/90-magento.ini /usr/local/etc/php/conf.d/90-magento.ini
COPY --chown=www-data:www-data --from=build /app /app
WORKDIR /app
USER www-data
CMD ["php-fpm"]

When to use it

  • Build one image in CI and run web, cron and consumers from it.
  • Stop a deploy from deleting images that merchandisers uploaded.
  • Find why a container serves the storefront with no CSS.
  • Give a new developer the same container layout the production cluster uses.

More examples

Three services from one image

The only shared writable path is pub/media. Cron has one instance; the consumer has restart: always because it is meant to exit when its message count is reached. Scale web with docker compose up -d --scale web=3, and never scale cron. Reach for this shape locally so the container boundaries match production before anything reaches Kubernetes.

Example · yaml
# docker-compose.yml - one build, three roles. Apply: docker compose up -d
services:
  web:
    image: store/app:1.42
    volumes:
      - media:/app/pub/media
      - ./secrets/env.php:/app/app/etc/env.php:ro
    command: ["php-fpm"]

  cron:
    image: store/app:1.42
    volumes:
      - media:/app/pub/media
      - ./secrets/env.php:/app/app/etc/env.php:ro
    command: ["sh", "-c", "while true; do bin/magento cron:run; sleep 60; done"]

  consumer-async:
    image: store/app:1.42
    restart: always
    volumes:
      - media:/app/pub/media
      - ./secrets/env.php:/app/app/etc/env.php:ro
    command: ["bin/magento", "queue:consumers:start", "async.operations.all",
              "--max-messages=1000", "--single-thread"]

volumes:
  media:

Writing env.php at container start

setup:config:set only writes app/etc/env.php, so it works before the store is installed and needs no database. Building env.php from environment variables keeps one image usable in every environment. Use it when you would otherwise bake a staging password into a layer, and flush the cache only on the run that wrote the file.

Example · bash
#!/bin/sh
# docker/entrypoint.sh - image CMD: ["sh", "/app/docker/entrypoint.sh", "php-fpm"]
set -eu

if [ ! -f app/etc/env.php ]; then
  bin/magento setup:config:set -n \
    --db-host="$DB_HOST" --db-name="$DB_NAME" \
    --db-user="$DB_USER" --db-password="$DB_PASSWORD" \
    --cache-backend=redis --cache-backend-redis-server="$REDIS_HOST" \
    --cache-backend-redis-db=0 \
    --page-cache=redis --page-cache-redis-server="$REDIS_HOST" \
    --page-cache-redis-db=1 \
    --session-save=redis --session-save-redis-host="$REDIS_HOST" \
    --session-save-redis-db=2 \
    --lock-provider=db
  bin/magento cache:flush
fi

exec "$@"

Applying a release's schema change from the new image

setup:db:status exits 2 when an upgrade is pending and 0 when it is not, which makes it the gate for the migration step. --keep-generated matters because the image already contains compiled DI and static content; without it, setup:upgrade clears generated/ inside that container. Reach for this on every release that ships a db_schema.xml change or a data patch.

Example · bash
# Run the migration from the image you are about to deploy, not from the old one.
docker run --rm --env-file .env \
  -v "$PWD/secrets/env.php:/app/app/etc/env.php:ro" \
  store/app:1.42 \
  sh -c 'bin/magento setup:db:status; [ $? -eq 2 ] && \
         bin/magento setup:upgrade --keep-generated || echo "schema current"'

# Run 'setup:upgrade' to update your DB schema and data.
# Cache cleared successfully
# Schema creation/updates:
# Module 'Vendor_Catalog':
# Running schema recurring...
# Module 'Vendor_Catalog':
# Running data recurring...

Discussion

  • Be the first to comment on this lesson.