PHP in a Container
Build a PHP image that holds only what production runs: the extensions you need, Composer in an earlier stage, and a non-root user.
RUN docker-php-ext-install -j"$(nproc)" pdo_mysql intl opcacheA container image is a filesystem plus a default command. You describe it in a Dockerfile and build it once, then run the identical bytes on your laptop and on the server. The official php images give you the interpreter and a few core extensions. Anything else you add yourself, deliberately.
Extensions
The official images ship helper scripts. docker-php-ext-install pdo_mysql is shorthand for compiling a bundled extension and writing its ini file; doing it by hand means phpize, configure, make and docker-php-ext-enable, and the result is the same. Extensions with system dependencies need headers at build time: install icu-dev, build intl, then remove the build packages so they do not ship.
Two stages
The first stage has Composer and runs composer install --no-dev --optimize-autoloader; -o is the same flag spelled short. The second stage is the runtime image and copies vendor/ out of the first. Composer, the compiler and your dev dependencies never reach production, and the image is smaller because of it.
What belongs outside
Run as www-data, not root, so a compromised worker cannot write to the application. Treat the container as disposable. Configuration and secrets arrive as environment variables, uploads go to a mounted volume or object storage, logs go to stdout. A .dockerignore keeps .git, .env and the host's vendor/ out of the build context in the first place.
Example
When to use it
- Give every developer the same PHP version and extension set as the server.
- Ship a release as one immutable image instead of copying files onto a host.
- Cut an image from 900MB to under 200MB by leaving Composer and the build toolchain in an earlier stage.
More examples
A multi-stage Dockerfile
Copying composer.json and composer.lock before the source is deliberate: the install layer is only rebuilt when those two files change, so most builds reuse it. $PHPIZE_DEPS is provided by the official image and names the compilers needed to build an extension; the virtual package .build-deps lets one apk del remove them all. icu-libs stays behind because intl needs it at runtime. The runtime stage ends as www-data, and nothing in it can install software.
FROM php:8.5-fpm-alpine AS base
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev \
&& docker-php-ext-configure intl \
&& docker-php-ext-install -j"$(nproc)" intl pdo_mysql opcache \
&& apk del .build-deps \
&& apk add --no-cache icu-libs
FROM base AS vendor
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist \
--no-interaction --no-progress --optimize-autoloader
FROM base AS runtime
WORKDIR /app
COPY deploy/php/99-production.ini /usr/local/etc/php/conf.d/99-production.ini
COPY --from=vendor /app/vendor ./vendor
COPY --chown=www-data:www-data . .
USER www-data
EXPOSE 9000
CMD ["php-fpm", "-F"]The .dockerignore that makes it safe
Everything listed here is excluded from the build context, so COPY cannot pick it up even by accident. vendor/ is excluded because the image builds its own copy; the host's version may have dev packages or the wrong platform's compiled code. Excluding .env is the difference between an image you can push to a registry and one that leaks credentials to everybody with pull access.
.git
.gitignore
.env
.env.*
vendor/
node_modules/
tests/
docker-compose*.yml
Dockerfile
*.md
storage/logs/*Wiring configuration in from outside
The same image runs in every environment and only these values change, which is the point of building it once. A secret is mounted as a file under /run/secrets, so the application reads the password with file_get_contents rather than taking it from an environment variable that any child process and most crash reporters can see. Uploads live on a named volume, because a container's own filesystem disappears with the container.
services:
app:
build: .
environment:
APP_ENV: production
DB_DSN: "mysql:host=db;dbname=shop;charset=utf8mb4"
DB_USER: shop
secrets:
- db_password
volumes:
- uploads:/app/var/uploads
depends_on:
- db
secrets:
db_password:
file: ./secrets/db_password
volumes:
uploads:
Discussion