Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Dockerfile Best Practices

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.

Multi-Stage Dockerfile FlowSourceGit repoBuild StageNode + ComposerRuntime StagePHP-FPM onlyRegistryTagged imageRuntime Image ContentsPHP 8.5Vendor dirPublic assetsNon-rootuserExcluded: Git, Node, Composer, dev packages
Dockerfile best practices use multi-stage builds to separate compile-time tools from the production runtime image

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.

Image Size: Before vs AfterBeforeSingle-stage build1.2 GB imageNode + Composer in runtimeFull .git in contextDev deps installedNo .dockerignoreAfterMulti-stage build280 MB imageRuntime PHP-FPM onlyStrict .dockerignore--no-dev Composerapt cache cleanedFaster pulls, smaller attack surface, cheaper registry storage
Applying Dockerfile best practices typically shrinks Laravel and PHP images by 60–80 percent

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.

TechniqueTypical savingsTrade-off
Multi-stage build40–70% size dropMore Dockerfile complexity
Strict .dockerignoreFaster builds, smaller contextMust maintain ignore rules
composer install --no-dev10–30% for PHP appsDev tools unavailable in container
Alpine vs Debian baseSmaller base layerExtension compatibility issues on Alpine
Distroless final stageMinimal runtime footprintHarder 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.

  1. Base image and system packages — changes rarely
  2. Dependency lockfilescomposer.lock, package-lock.json
  3. Install dependencies — cache hit when locks are unchanged
  4. Copy application source — changes on every commit
  5. Build and compile steps — run after source copy only when needed
  6. Runtime user and CMD — stable across builds
Layer Cache Invalidation OrderLayer 1: FROM php:8.3-fpm — cached (stable base)Layer 2: apt packages — cached (rare changes)Layer 3: COPY composer.lock — cached (lock unchanged)Layer 4: composer install — rebuilt (lock changed)Layer 5+: COPY app source — always rebuilt on commitChange at layer N invalidates layers N through the final image
Correct instruction order in Dockerfile best practices keeps dependency layers cached across frequent code commits

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.

Dockerfile to Production PipelineGit Pushmain branchCI BuildBuildKit cacheScanTrivy/SnykRegistryTagged digestDeployK8s/VMQuality Gates Before DeployUnit testsImage scanNon-root checkSize limitPin digests in production manifests for reproducible rollbacks
Dockerfile best practices extend into CI with image scanning, size limits, and digest-pinned deploys

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 USER before CMD
  • Using ADD instead of COPYADD has tar extraction and URL fetch behaviour you rarely want
  • Installing latest packages without version pins — rebuilds become non-reproducible
  • Multiple consecutive RUN apt-get update lines — 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 latest tags 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

Pin base images, use multi-stage builds, order instructions for cache hits, run as a non-root user, exclude secrets from layers, and keep runtime images free of compilers and dev dependencies.

Production Dockerfiles for Laravel 13 on PHP 8.3 or 8.5 should produce a small, immutable image that runs one process well. Use multi-stage builds: a Node.js 26 stage compiles Vite assets, a Composer 2.10 stage installs dependencies with composer install --no-dev, and a php:8.3-fpm-bookworm runtime stage ships only PHP extensions, Opcache config, vendor, and compiled public/build output. Never include Git, Node, or Composer in the final image. Pin explicit base tags, and use digest pins when you need reproducibility across months of deploys because digests survive upstream tag moves.

Image bloat usually comes from 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 so node_modules, vendor, .git, tests, and .env never enter the build context. Chain apt-get and extension installs in one RUN and delete /var/lib/apt/lists in the same step, otherwise deleted files still exist in earlier layers. Prefer slim Debian bases for PHP-FPM and composer install --no-dev. Applying these practices typically shrinks Laravel and PHP images by 60 to 80 percent.

Never bake secrets into image layers with ENV DB_PASSWORD or copied .env files; they persist in layer history and anyone with registry pull access can extract them. Treat images as public-readable even on private registries. For compile-time credentials such as private Composer registries, use BuildKit mount secrets with DOCKER_BUILDKIT=1 instead of ARG values that end up in metadata. Runtime secrets belong in Kubernetes secrets, Docker Swarm secrets, or your CI/CD vault at deploy time. Use ARG for build-time constants like APP_ENV during asset compilation; runtime configuration should start with the container unless you intentionally bake per-environment images.

Docker rebuilds a layer and every layer after it when an instruction changes, so order from least frequent change to most: base image and system packages, dependency lockfiles, dependency installation, application source copy, build and compile steps, then runtime user and CMD. A common mistake is copying the entire project before composer install, which busts the vendor cache on every Blade template change. Copy composer.json and composer.lock first, run composer install --no-dev --no-scripts, copy the rest, then run composer dump-autoload --optimize. Enable BuildKit in GitLab CI or GitHub Actions so cache mounts and parallel stage builds persist between pipeline runs.

Multi-stage builds are the single highest-impact pattern for PHP and Node applications: name stages clearly and copy only artefacts forward so build tools never reach the final layer. Add HEALTHCHECK for orchestrators like Kubernetes; for PHP-FPM behind Nginx, probe port 9000 or prefer a real HTTP endpoint that hits the database cache layer. Always set WORKDIR before COPY and RUN, and add OCI LABEL metadata for source URL and version so ops teams can trace a running container back to a git commit. One Dockerfile per process role beats one mega-container running web, worker, cron, and scheduler together.

Recurring failures I see in client codebases include running as root, using ADD instead of COPY, installing latest packages without version pins, multiple consecutive RUN apt-get update lines, logging secrets in build output, skipping image vulnerability scans, and cramming web, worker, cron, and scheduler into one container. For WordPress 7.1 or WooCommerce 11.1, extend official images with custom plugins and themes but do not inherit root user or bloated dev tooling from a quick prototype Dockerfile. Validate JSON config files in CI before they get baked into images, because broken config inside a container is harder to debug than a failed pipeline step.

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, default to bookworm-slim variants unless you have a specific size constraint and have tested every extension on Alpine first.

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.

Yes. Many teams containerise staging and CI while keeping production on symlink-based Deployer 7 releases on bare metal or VMs. The Dockerfile still matters for consistent dev environments and CI test runs. Sister sites on shared EC2 infrastructure use both Deployer symlink releases and containerised staging depending on client ops maturity. Keep environment parity close enough that code behaves the same in both paths. When containers sit alongside Deployer on the same Ubuntu 22 or 24 server, match UID and GID between the container user and the host deploy user so PHP-FPM on the host can read files the container wrote.

COPY copies files from the build context. ADD also auto-extracts tar archives and can fetch remote URLs—behaviour you rarely want. Prefer COPY unless you specifically need ADD's extra features.

Floating latest tags silently change upstream content between builds, breaking reproducibility and making it harder to trace failures. Pin explicit tags like php:8.3-fpm-bookworm or node:26-bookworm, and pin to a digest when you need identical bytes across months of deploys because digest pins survive upstream tag moves. A pinned digest is reproducible, but you still need a process to bump it after security advisories. Schedule monthly base image rebuilds even when application code is unchanged, document the bump in your changelog, and run smoke tests before promoting the new tag.

Your build context should not include node_modules, vendor, .git, test fixtures, or local .env files because a large context slows every build and increases cache invalidation risk. A strict .dockerignore for Laravel typically lists .git, node_modules, vendor, storage/logs, storage/framework/cache, storage/framework/sessions, storage/framework/views, tests, .phpunit.result.cache, .env, .env.*, docker-compose files, and README.md. Excluding these directories keeps dependency layers cached across frequent code commits and prevents accidental inclusion of local secrets or log data in image layers.

No. Running as root widens the attack surface if a process or dependency is compromised. Create a dedicated user with useradd, chown the application directory, set USER before CMD, and run php-fpm as that account. On deployments where containers share a server with traditional PHP-FPM via Deployer, align the container user's UID and GID with the host deploy user. I have debugged production failures where a container wrote files as root and PHP-FPM on the host could not read them because ownership did not match.

Use multi-stage builds with clearly named stages. A node:26-bookworm stage runs npm ci and npm run build after copying only package.json and package-lock.json for cache hits. A composer:2.10 stage runs composer install --no-dev --prefer-dist --optimize-autoloader from composer.json and composer.lock alone. The final php:8.3-fpm-bookworm runtime stage uses COPY --from to bring in vendor and public/build artefacts only. Git, Node, and Composer never reach the final layer, which keeps attack surface and image size down on production Laravel deployments I structure this way.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: