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.
docker build -t store/app:1.42 . && docker push store/app:1.42A 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
# 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.
# 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.
#!/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.
# 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