Docker for PHP in production: a practical setup guide for business applications
Docker makes a PHP application run the same way on a laptop, in testing and in production, and makes releases and rollbacks routine instead of stressful. It also adds moving parts, and a careless setup is worse than a plain server. This guide shows a production-ready baseline with real configuration, and when you should not bother.
TL;DR
- Docker is worth it when you want identical environments, fast rollbacks and easy onboarding. For a single small brochure site on shared hosting it is usually unnecessary.
- Build an immutable image in CI: multi-stage build, production dependencies only, non-root user, OPcache on, errors logged and never displayed.
- Release by image tag (the Git commit hash), check health after start and roll back to the previous tag automatically if the check fails.
- Containers are disposable, data is not: keep the database and uploads in volumes or managed services and back them up outside Docker.
- The most common production mistakes: secrets in the image, published database ports, unbounded logs and php-fpm limits that do not match the memory you have.
For many business applications the real cost of hosting is not the server but the people. Setup that only one developer understands, a deployment done by hand over FTP, and a staging environment that behaves differently from production are all expensive in the long run. Containers address this by packaging the application with its exact PHP version and extensions. We run our own PHP sites and client systems on a Docker-based stack, and this guide describes the baseline we start from.
When Docker is worth it, and when it is not
- Worth it: several services (web, PHP, database, queue), more than one developer, a need for repeatable releases and quick rollbacks, or a plan to move between providers.
- Not necessary: a simple content site that rarely changes on managed hosting. Added complexity needs a payoff.
- Not a replacement for good operations: you still need patching, backups and monitoring. See our backup and monitoring guide.
The target architecture
A solid, simple setup for a PHP application on one server:
- Reverse proxy and TLS at the edge (Nginx or Caddy on the host, or a CDN such as Cloudflare, see our Cloudflare setup guide).
- Web container (Nginx) that serves static files and passes PHP requests on.
- PHP-FPM container with your code, built into the image.
- Database in its own container with a persistent volume, or better a managed database service for critical data.
- Volumes only for data that must survive: database files and user uploads.
A multi-stage Dockerfile
A multi-stage build installs dependencies in one stage and copies only the result into a slim runtime image. The final image has no Composer, compilers or test tools, which makes it smaller and gives attackers less to use:
# ---- stage 1: PHP dependencies ----
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction --optimize-autoloader
# ---- stage 2: runtime ----
FROM php:8.3-fpm-alpine
RUN apk add --no-cache icu-libs libzip \
&& apk add --no-cache --virtual .build icu-dev libzip-dev $PHPIZE_DEPS \
&& docker-php-ext-install -j"$(nproc)" pdo_mysql intl zip opcache \
&& apk del .build
COPY docker/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY docker/www.conf /usr/local/etc/php-fpm.d/zz-www.conf
WORKDIR /var/www/app
COPY --from=vendor /app/vendor ./vendor
COPY --chown=www-data:www-data . .
USER www-data
EXPOSE 9000
Choose the PHP version deliberately and check that it is still supported. Our PHP 8 upgrade guide explains how to plan that. Pin versions of base images and update them on a schedule, since an image you never rebuild accumulates known vulnerabilities. Keep a .dockerignore that excludes .git, .env, tests and local data, so nothing sensitive ends up in the image.
Production PHP and php-fpm configuration
# docker/php.ini
expose_php = 0
display_errors = 0
log_errors = 1
memory_limit = 256M
upload_max_filesize = 10M
post_max_size = 12M
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0 ; the code in an image never changes while it runs
# docker/www.conf
[www]
pm = dynamic
pm.max_children = 12
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500
request_terminate_timeout = 30s
catch_workers_output = yes
decorate_workers_output = no
Setting opcache.validate_timestamps = 0 gives the best performance for an immutable image, but it only works if you never edit files inside a running container: you release a new image instead. Size the pool from real numbers: measure the average memory of one PHP worker under load, decide how much memory PHP may use on the server, and divide. If a worker uses 60 MB and you can give PHP 1 GB, pm.max_children is about 16. Setting it much higher just makes the server swap or the kernel kill processes during a traffic spike. Too low and requests queue. Check the php-fpm log for "max_children reached" warnings.
A Compose file with the right defaults
x-logging: &default-logging
driver: json-file
options: { max-size: "10m", max-file: "5" } # logs cannot fill the disk
services:
web:
image: ghcr.io/your-org/app-web:${TAG}
ports: ["127.0.0.1:8080:80"] # only the local reverse proxy can reach it
depends_on: [php]
restart: unless-stopped
logging: *default-logging
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/health.php"]
interval: 15s
timeout: 3s
retries: 3
php:
image: ghcr.io/your-org/app:${TAG}
env_file: .env # secrets stay on the server, not in the image
restart: unless-stopped
read_only: true
tmpfs: [/tmp]
mem_limit: 512m
volumes: [uploads:/var/www/app/storage/uploads]
logging: *default-logging
db:
image: mysql:8.4
env_file: .env.db
restart: unless-stopped
volumes: [db_data:/var/lib/mysql] # no "ports:" - the database is not public
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 15s
retries: 5
logging: *default-logging
volumes:
uploads:
db_data:
The web image is a small Nginx image with your public/ folder and site configuration copied in, built in the same pipeline as the PHP image. Key points: bind published ports to 127.0.0.1, never publish the database, set restart policies, limit memory so one container cannot take down the host, and cap log size. Remember that ports published by Docker can bypass host firewall rules, as we explain in the Cloudflare guide. Never mount the Docker socket into an application container, because it gives that container control over the host.
Releases and rollbacks
The best feature of an image-based setup is that every release is a tag, and a rollback is "start the previous tag". Tag images with the Git commit hash, never rely on latest, and make the release script verify health:
#!/usr/bin/env bash
# /srv/app/release.sh <git-sha>
set -euo pipefail
TAG="${1:?usage: release.sh <git-sha>}"
cd /srv/app
PREV="$(cat .current_tag 2>/dev/null || true)"
export TAG
docker compose pull php web
docker compose up -d --no-deps php web
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8080/health.php > /dev/null; then
echo "$TAG" > .current_tag
echo "healthy: $TAG"; exit 0
fi
sleep 2
done
echo "unhealthy after release, rolling back to ${PREV:-none}" >&2
if [ -n "$PREV" ]; then TAG="$PREV" docker compose up -d --no-deps php web; fi
exit 1
Database changes need care: write migrations so the previous version of the code still works with the new schema (add columns first, remove them in a later release). That is what makes a quick rollback safe. Take a database backup before any release that changes the schema.
Building and shipping from CI
Build the image once in CI and deploy exactly that artifact. A minimal GitHub Actions workflow:
name: build-and-release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/your-org/app:${{ github.sha }}
- name: Release over SSH
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
install -m 600 /dev/null key && echo "$DEPLOY_KEY" > key
ssh -i key -o StrictHostKeyChecking=accept-new deploy@your-server "/srv/app/release.sh ${{ github.sha }}"
Run your tests and static analysis in the same pipeline before the image is built, and use a deploy key that can only run the release script. Add an image vulnerability scan if your risk profile demands it.
Logs, data and security
- Logs go to stdout and stderr, are rotated by Docker and, for anything important, shipped to a central place so they survive the container.
- Data lives outside the container. A volume is not a backup. Use the approach in our backup guide: encrypted, off-site, restore-tested.
- Secrets are never baked into images. Pass them at runtime through env files or a secrets manager and keep them out of Git.
- Run as a non-root user, use a read-only filesystem where possible, and drop what you do not need.
- Keep images fresh. Rebuild regularly to pick up security fixes in the base image and PHP.
- Apply the application-level controls from our PHP security checklist, since containers do not fix insecure code.
A production readiness checklist
- Multi-stage image with production dependencies only, pinned base image, non-root user.
- OPcache enabled, errors logged not displayed, php-fpm sized from measured memory.
- Compose file with restart policies, healthchecks, memory limits, log rotation and no public database port.
- Releases by commit-hash tag with an automatic health check and rollback.
- Backups and a tested restore for the database and uploads.
- Monitoring and alerts on health, disk, errors and certificates.
How we can help
We containerize PHP applications, set up CI pipelines and releases, and connect them to payments, CRM and messaging. See our integrations service or book a call and we will review your current hosting and release process.
Frequently asked questions
Is Docker good for running PHP in production?
Yes, when used well. It gives identical environments, repeatable releases and fast rollbacks. It needs proper configuration: immutable images, health checks, resource limits, log rotation, secrets management and backups of data outside the container.
Should the database run in Docker in production?
It can for small and medium systems if it uses a persistent volume, is never exposed publicly and has tested backups. For critical data many teams prefer a managed database service, which handles failover, updates and backups for you.
How do I roll back a Docker release?
Tag every image with the Git commit hash and keep the previous tag. To roll back, start the containers with the previous tag. Write database migrations so the older code still works with the newer schema.
How many php-fpm workers should I configure?
Measure the average memory of one worker under load, decide how much memory PHP may use, and divide. Setting the value too high causes swapping, while too low causes queued requests. Watch the php-fpm log for max_children warnings.
What is the biggest Docker security mistake with PHP apps?
Putting secrets in the image or Git and publishing database ports to the internet. Also avoid running as root, mounting the Docker socket into containers and never updating the base image.
Free discovery call
Bring a messy stack or stalled integration - Integrono will map the smartest first move.
You get a senior take on budget traps, dependencies and whether to buy, glue or build - before paid work starts. The CEO stays in the loop.