
September 10, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
A bloated Dockerfile wastes CI minutes, leaks secrets, and ships images that are painful to patch. Dockerfile best practices turn your container build from a fragile one-off script into a repeatable production pipeline. Whether you containerise a Laravel 13 API, a WordPress 7.1 site, or a Node.js 26 worker, the same rules apply. Minimise layers, pin base images, run as non-root, and keep build tools out of runtime images. On deployments I maintain alongside GitLab CI and Deployer 7 workflows, Docker images are the artefact that must stay boring and predictable. This guide covers layer caching, multi-stage builds, security hardening, and the mistakes I still see in client codebases.
What are Dockerfile best practices for production Laravel and PHP apps?
Production Dockerfiles should produce a small, immutable image that runs one process well. For PHP 8.3 or 8.5 on Laravel 13, that usually means a multi-stage build: one stage compiles assets and installs Composer dependencies, another ships only what PHP-FPM needs at runtime.
Start with an explicit base tag. Never use php:latest or node:latest. Pin to a digest when you need reproducibility across months of deploys. A digest pin survives upstream tag moves that silently change your build.
On a legal-tech portal I built with Laravel and MySQL, the runtime image contained PHP extensions, Opcache config, and compiled front-end assets. It did not contain Git, Node, or Composer. That separation keeps attack surface and image size down.
Here is a production-oriented pattern for Laravel 13 on PHP 8.3. Adjust extension names for your app.
# syntax=docker/dockerfile:1
FROM node:26-bookworm AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build
FROM composer:2.10 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader
FROM php:8.3-fpm-bookworm AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libpng-dev libicu-dev \
&& docker-php-ext-install pdo_mysql zip intl opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=vendor /app/vendor /var/www/html/vendor
COPY --from=assets /app/public/build /var/www/html/public/build
COPY . /var/www/html
RUN useradd -ms /bin/bash appuser \
&& chown -R appuser:appuser /var/www/html
USER appuser
EXPOSE 9000
CMD ["php-fpm"] This pattern aligns with how I structure enterprise Laravel deployments. Build tools never reach the final layer. Your image size reduction checklist starts here.
How do you reduce Docker image size without breaking builds?
Image bloat usually comes from four sources: fat base images, dev dependencies in production, poor .dockerignore coverage, and apt cache left inside layers. Fix all four before chasing exotic optimisations.
Use a strict .dockerignore
Your build context should not include node_modules, vendor, .git, test fixtures, or local .env files. A large context slows every build and increases cache invalidation risk.
.git
node_modules
vendor
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
tests
.phpunit.result.cache
.env
.env.*
docker-compose*.yml
README.md Combine RUN instructions and clean apt cache
Each RUN creates a layer. Chain related commands in one RUN and delete caches in the same step. Otherwise deleted files still exist in earlier layers and inflate image size.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& docker-php-ext-install pdo_pgsql \
&& apt-get purge -y --auto-remove \
&& rm -rf /var/lib/apt/lists/* Prefer slim or distroless bases where possible
For static Go or Rust binaries, distroless images are excellent. For PHP-FPM, you still need a glibc base with extensions. Use official slim variants and install only required packages. Do not copy your entire development laptop into the container.
The official Docker documentation on writing efficient Dockerfiles recommends the same principles: minimise layers, use multi-stage builds, and exclude unnecessary files. Pair that with deployment speed work when images feed a slow release pipeline.
| Technique | Typical savings | Trade-off |
|---|---|---|
| Multi-stage build | 40–70% size drop | More Dockerfile complexity |
| Strict .dockerignore | Faster builds, smaller context | Must maintain ignore rules |
composer install --no-dev | 10–30% for PHP apps | Dev tools unavailable in container |
| Alpine vs Debian base | Smaller base layer | Extension compatibility issues on Alpine |
| Distroless final stage | Minimal runtime footprint | Harder to debug interactively |
How should you handle secrets and environment variables in a Dockerfile?
Never bake secrets into image layers. A ENV DB_PASSWORD=... line or a copied .env file persists in layer history. Anyone with registry pull access can extract it. Treat images as public-readable even on private registries.
Use BuildKit secrets for compile-time credentials
Private Composer or npm registries need tokens during build. Pass them with BuildKit mount secrets instead of ARG values that end up in metadata.
# syntax=docker/dockerfile:1
FROM composer:2.10 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=composer_auth,target=/tmp/auth.json \
COMPOSER_AUTH="$(cat /tmp/auth.json)" \
composer install --no-dev --prefer-dist Build with:
DOCKER_BUILDKIT=1 docker build \
--secret id=composer_auth,src=$HOME/.composer/auth.json \
-t myapp:latest . Runtime secrets belong in your orchestrator or host environment. Inject them at deploy time through Kubernetes secrets, Docker Swarm secrets, or your CI/CD vault. Read more in our guide on CI/CD secrets management.
Separate build args from runtime config
ARG values are for build-time constants like APP_ENV=production during asset compilation. Runtime configuration should come from environment variables set when the container starts, not from values frozen at build time unless you intentionally bake per-environment images.
On shared EC2 infrastructure where I run Deployer alongside containers, I keep one image tag per release. Environment-specific values live in the orchestrator, not in the Dockerfile. That matches server hardening patterns I apply on Ubuntu 22 and 24 hosts.
What is the correct order for Dockerfile instructions to maximise cache hits?
Docker rebuilds a layer and every layer after it when an instruction changes. Order instructions from least frequent change to most frequent change. Dependency manifests change less often than application source code.
- Base image and system packages — changes rarely
- Dependency lockfiles —
composer.lock,package-lock.json - Install dependencies — cache hit when locks are unchanged
- Copy application source — changes on every commit
- Build and compile steps — run after source copy only when needed
- Runtime user and CMD — stable across builds
A common mistake is copying the entire project before composer install. One line change in a Blade template busts the vendor install cache. Copy only manifest files first, install, then copy the rest.
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts
COPY . .
RUN composer dump-autoload --optimize Enable BuildKit in CI for cache mounts and parallel stage builds. GitLab CI, GitHub Actions, and similar runners support BuildKit with a simple env flag. Tie this into your wider build pipeline automation strategy so cache exports persist between pipeline runs.
How do multi-stage builds and health checks fit into Dockerfile best practices?
Multi-stage builds are the single highest-impact pattern for PHP and Node applications. The Docker multi-stage build guide shows the general pattern. For Laravel, name stages clearly and copy only artefacts forward.
Add a health check for orchestrators
Platforms like Kubernetes use health checks to restart unhealthy containers. For PHP-FPM behind Nginx, check the FPM ping endpoint or a lightweight HTTP probe through your web server sidecar.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD php -r "exit(@fsockopen('127.0.0.1', 9000) ? 0 : 1);" For apps with a public HTTP port, prefer a real endpoint that hits the database cache layer. That catches more production failures than a port-open check alone.
Set explicit WORKDIR and LABEL metadata
Always set WORKDIR before COPY and RUN. Add OCI labels for maintainer, source URL, and version. They cost nothing and help operations teams trace a running container back to a git commit.
LABEL org.opencontainers.image.source="https://gitlab.com/org/myapp"
LABEL org.opencontainers.image.version="1.4.2" Projects like Adventure Third Pole Trek run Laravel with Livewire, queues, and scheduled tasks. Container images for those apps need Supervisor or separate worker containers defined in compose or Kubernetes manifests. One Dockerfile per process role beats one mega-container running six services.
What Dockerfile mistakes cause slow CI and insecure containers?
These failures show up repeatedly on client projects and in code review. Each one is fixable in an afternoon.
- Running as root — create a dedicated user with
USERbeforeCMD - Using
ADDinstead ofCOPY—ADDhas tar extraction and URL fetch behaviour you rarely want - Installing
latestpackages without version pins — rebuilds become non-reproducible - Multiple consecutive
RUN apt-get updatelines — merge them; stale cache causes confusing apt errors - Logging secrets in build output — never echo tokens; use secret mounts
- Skipping image vulnerability scans — base image CVEs need regular rebuilds
- One container running web, worker, cron, and scheduler — split into focused images or services
For WordPress 7.1 or WooCommerce 11.1 containers, the same rules apply even when official images exist. Extend them with custom plugins and themes, but do not inherit root user or bloated dev tooling from a quick prototype Dockerfile. Our WordPress development service often starts by auditing an existing container setup before adding features.
Validate JSON config files in your pipeline with a JSON formatter and linter before they get baked into images. Broken config inside a container is harder to debug than a failed CI step.
When containers sit alongside traditional Deployer releases on the same server, keep ownership and volume mount permissions consistent. I have debugged production failures where a container wrote files as root and PHP-FPM on the host could not read them. Match UID and GID between container user and host deploy user.
For API-heavy Laravel apps, pair container hardening with Laravel API design standards and REST API conventions. Security is a stack concern, not a Dockerfile-only concern. Review OWASP-aligned Laravel hardening alongside your container work.
If you are new to my approach, see the about page for background on how I ship production systems in Nepal and internationally. Sister sites on shared infrastructure use both Deployer symlink releases and containerised staging environments depending on client ops maturity.
Testing container builds locally before CI saves hours. Run docker build --progress=plain to see exact layer output. Use dive or docker history to inspect what each layer added. Schedule monthly base image rebuilds even when application code is unchanged. CVE patches land in upstream bases constantly. A pinned digest is reproducible, but you still need a process to bump that digest after security advisories. Document the bump in your changelog and run smoke tests before promoting the new tag.
Key Takeaways
- Pin base images by tag or digest; never rely on floating
latesttags in production. - Use multi-stage builds to keep Node, Composer, and compilers out of runtime images.
- Order Dockerfile instructions from stable to volatile so dependency layers stay cached.
- Never store secrets in layers; use BuildKit secret mounts and runtime env injection.
- Run containers as a non-root user and add health checks for orchestrator compatibility.
- Scan images in CI, enforce size limits, and rebuild bases regularly for CVE patches.
People Also Ask
Should I use Alpine or Debian for PHP Docker images?
Debian-based official PHP images have the best extension compatibility for Laravel and Symfony. Alpine is smaller but musl libc causes subtle bugs with some PHP extensions and PECL packages. For production PHP 8.3 or 8.5, I default to bookworm-slim variants unless you have a specific size constraint and have tested every extension.
How often should you rebuild Docker images?
Rebuild on every application release at minimum. Schedule weekly or monthly base-only rebuilds to pick up OS and runtime security patches even when your code is unchanged. Automate this in CI so rebuilds are routine, not emergency fire drills after a CVE announcement.
Can you use Docker and traditional Deployer deployments together?
Yes. Many teams containerise staging and CI while keeping production on symlink-based Deployer releases on bare metal or VMs. The Dockerfile still matters for consistent dev environments and CI test runs. Keep environment parity close enough that code behaves the same in both paths.
What is the difference between COPY and ADD in a Dockerfile?
COPY transfers local files into the image with no surprises. ADD can fetch URLs and auto-extract tar archives, which makes builds less predictable. Docker's own best-practices documentation recommends COPY unless you explicitly need ADD's extra behaviour.
Ship containers that are small, secure, and boring to operate
Dockerfile best practices are not about clever tricks. They are about reproducible builds, minimal runtime images, and secrets kept out of layer history. Start with multi-stage builds, a tight .dockerignore, non-root users, and BuildKit caching in CI. Those four changes fix most of the Docker pain I see on real client projects. Need help containerising a Laravel app or hardening an existing pipeline? Get in touch or explore Linux system administration support and ongoing maintenance options. For related reading, see Laravel coding standards and testing and optimisation services.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

