Dockerizing a Laravel App the Right Way: Multi-Stage Builds and Compose - NextGenBeing Dockerizing a Laravel App the Right Way: Multi-Stage Builds and Compose - NextGenBeing
Back to discoveries

Dockerizing a Laravel App the Right Way: Multi-Stage Builds and Compose

A stock Laravel 12 skeleton, built with the Dockerfile most tutorials hand you, lands at 1.24 GB. It ships Composer, git, unzip, the full Node 22 toolchain, nodemodules/ (398 MB on a default Vite…

Web Development 18 min read
Bekzod Erkinov

Bekzod Erkinov

Sep 25, 2026 • 0 views
Dockerizing a Laravel App the Right Way: Multi-Stage Builds and Compose
Photo by imgix on Unsplash
Size:
Height:
📖 18 min read 📝 6,050 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 11 sections

Dockerizing a Laravel App the Right Way: Multi-Stage Builds and Compose

A stock Laravel 12 skeleton, built with the Dockerfile most tutorials hand you, lands at 1.24 GB. It ships Composer, git, unzip, the full Node 22 toolchain, node_modules/ (398 MB on a default Vite + Tailwind install), and the entire .git directory into a container whose only job is answering HTTP requests. Change one character in a Blade template and the COPY . . layer is invalidated, which triggers a full composer install plus npm ci — roughly three to four minutes of rebuild for a template typo.

Then the first deploy fails with this:

In Connection.php line 793:

  SQLSTATE[HY000] [2002] Connection refused

That is not a Docker networking bug. It happens because php artisan config:cache ran during docker build, when DB_HOST was still 127.0.0.1 from the committed .env.example, and the cached config froze that value into bootstrap/cache/config.php permanently.

This tutorial rebuilds that image properly: a four-stage Dockerfile producing a 188 MB runtime image that rebuilds in about eight seconds when only PHP code changes, plus a Compose file wiring PHP-FPM, Nginx, MySQL, Redis, a queue worker, and the scheduler together with real readiness gating instead of hopeful depends_on.

Why the naive Dockerfile is structurally wrong

Here is the shape of the problem:

FROM php:8.4-fpm

RUN apt-get update && apt-get install -y \
    git zip unzip libpq-dev libpng-dev nodejs npm

RUN docker-php-ext-install pdo_mysql gd

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY . /var/www/html
WORKDIR /var/www/html

RUN composer install
RUN npm install && npm run build
RUN php artisan config:cache

CMD ["php-fpm"]

Four distinct defects, each with a concrete cost:

  1. COPY . /var/www/html sits above the expensive steps. Docker's build cache is keyed on the instruction text plus a checksum of the files it copies. Touch any file and every layer below is invalidated. The Docker build cache documentation states the rule plainly: order instructions from least-frequently-changed to most-frequently-changed. Dependency manifests change rarely; application code changes constantly. They must be copied separately.

  2. Build tooling stays in the final image. git, node, npm, Composer, and the compiler toolchain pulled in by docker-php-ext-install are all present at runtime. That is dead weight and extra attack surface — a shell in your web container should not come with a package manager and a C compiler.

  3. composer install without --no-dev installs PHPUnit, Mockery, Faker, Pint, and the whole debug stack. Faker alone ships tens of thousands of locale files.

  4. config:cache at build time bakes build-time environment into the artifact. That is the Connection refused above.

The fix for all four is one structural idea: separate the machinery that produces artifacts from the image that runs them.

Choosing a base image and installing extensions

There are two realistic choices:

  • php:8.4-fpm-alpine — smallest, musl libc, roughly 30 MB compressed.
  • php:8.4-fpm-bookworm — glibc, around 100 MB compressed, better compatibility with vendor-supplied binary extensions.

Alpine is the right default for a typical Laravel app. Move to Debian if you need Microsoft's sqlsrv/pdo_sqlsrv drivers (no musl builds exist), Oracle's oci8, or any proprietary .so linked against glibc. A musl-linked binary failing to load looks like this, and nothing in the message suggests libc is the culprit:

PHP Warning:  PHP Startup: Unable to load dynamic library 'sqlsrv.so'
  ... Error relocating /usr/local/lib/php/extensions/no-debug-non-zts/sqlsrv.so:
  __snprintf_chk: symbol not found

Compiling extensions by hand means tracking header packages (libpng-dev, jpeg-dev, icu-dev, oniguruma-dev, libzip-dev) and remembering to remove them afterwards. mlocati/docker-php-extension-installer resolves those dependencies and strips build-only packages within the same layer:

FROM php:8.4-fpm-alpine AS base

COPY --from=mlocati/php-extension-installer:2 \
     /usr/bin/install-php-extensions /usr/local/bin/

RUN install-php-extensions \
        bcmath \
        exif \
        gd \
        intl \
        opcache \
        pcntl \
        pdo_mysql \
        redis \
        zip \
    && rm /usr/local/bin/install-php-extensions

WORKDIR /var/www/html

Two of those are non-negotiable in production:

  • pcntl — without it, queue workers cannot trap SIGTERM, so docker stop kills a worker mid-job instead of letting it drain. Horizon refuses to boot at all, with The pcntl extension is required.
  • opcache — leaving it off costs a two-to-fivefold throughput penalty on every request, because PHP re-parses and re-compiles every class file each time.

Add the production INI baseline in the same stage:

COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/
COPY docker/php/app.ini     /usr/local/etc/php/conf.d/
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

docker/php/opcache.ini:

opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.save_comments=1

opcache.max_accelerated_files is the setting people get wrong. The default is 10,000, and the value is rounded up to the next prime. A Laravel app with a moderate vendor tree crosses that easily — find vendor app bootstrap config -name '*.php' | wc -l on a mid-sized project commonly returns 12,000 to 18,000. Once the table fills, OPcache silently stops caching new files and you end up with a partially compiled application that performs worse than either extreme. The complete parameter list lives in the PHP OPcache configuration manual.

opcache.save_comments=1 matters because anything reading docblock annotations at runtime — including several Doctrine-derived packages — breaks without them.

validate_timestamps=0 means PHP never stats files to check for changes. Exactly right for an immutable image, exactly wrong for a bind-mounted dev container, so Compose overrides it later.

docker/php/app.ini:

memory_limit=256M
upload_max_filesize=32M
post_max_size=32M
max_execution_time=30
expose_php=0
realpath_cache_size=4096K
realpath_cache_ttl=600
date.timezone=UTC

The .dockerignore file does half the work

Write this before touching the Dockerfile. Without it, COPY . . ships your entire working tree — node_modules, a local vendor/, .git — to the build daemon as context, and a change to any of those files busts the cache.

.git
.github
.idea
.vscode
node_modules
vendor
public/build
public/hot
public/storage
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
.env
.env.*
!.env.example
tests
phpunit.xml
*.md
docker-compose*.yml
Dockerfile

Ignoring vendor is deliberate. A vendor/ built on macOS and copied into a Linux image carries an installed.php full of host paths and platform assumptions. Let the build produce it.

Ignoring .env is a security control, not an optimisation. A .env copied into an image is permanently readable by anyone who can pull that image — layers are not erased by a later RUN rm.

Stage by stage: the real Dockerfile

Four stages: base (shared runtime), vendor (Composer), assets (Node), app (final).

Stage two — PHP dependencies

FROM base AS vendor

COPY --from=composer/composer:2-bin /composer /usr/bin/composer

ENV COMPOSER_ALLOW_SUPERUSER=1 \
    COMPOSER_MEMORY_LIMIT=-1

COPY composer.json composer.lock ./

RUN --mount=type=cache,target=/tmp/composer,sharing=locked \
    COMPOSER_CACHE_DIR=/tmp/composer \
    composer install \
        --no-dev \
        --no-interaction \
        --no-progress \
        --prefer-dist \
        --no-scripts \
        --no-autoloader

COPY . .

RUN composer dump-autoload --no-dev --optimize --classmap-authoritative \
    && composer run-script post-autoload-dump --no-dev

Three details carry real weight here.

--no-scripts on the install. Laravel's composer.json registers @php artisan package:discover --ansi on post-autoload-dump. That command boots the framework, which needs the full application tree — bootstrap/app.php, config/, artisan. At install time we have only composer.json and composer.lock, so running scripts fails:

Could not open input file: artisan
Script @php artisan package:discover --ansi handling the post-autoload-dump event returned with error code 1

Splitting install from autoload generation is precisely what lets the dependency layer be cached independently of application code. composer.lock changes maybe weekly; app/ changes hourly.

The BuildKit cache mount. --mount=type=cache gives Composer a persistent download cache living outside the image layers. It makes a cold-lock rebuild dramatically faster without adding a byte to the result. It requires BuildKit, the default in Docker Engine 23+ and in docker buildx. If you see the --mount option requires BuildKit, export DOCKER_BUILDKIT=1.

--classmap-authoritative. This tells the autoloader the classmap is the complete truth and to skip PSR-4 filesystem probing entirely, eliminating a stat() storm on cold requests. The trade-off: any class generated on disk at runtime becomes unloadable. In practice that only affects test doubles and a handful of packages writing proxy classes at runtime. If you hit a Class "App\Something" not found that only occurs inside the container, fall back to plain --optimize.

Stage three — front-end assets

FROM node:22-alpine AS assets

WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --no-audit --no-fund

COPY vite.config.js ./
COPY resources ./resources
COPY --from=vendor /var/www/html/vendor ./vendor

RUN npm run build

npm ci rather than npm install: it installs exactly what package-lock.json pins and errors out when the lockfile and package.json disagree, instead of silently rewriting the lock mid-build.

The vendor copy is not decoration. Laravel's pagination and mail templates live under vendor/laravel/framework/src/Illuminate/Pagination/resources/views, and a Tailwind v4 config with @source "../vendor/laravel/framework/src/Illuminate/Pagination" emits zero pagination classes when that path is missing. The symptom is unstyled pagination links in production and correct ones locally — a discrepancy that costs far more time to diagnose than the two-line fix.

The build writes public/build/manifest.json plus hashed assets. Laravel's @vite directive reads that manifest; when it is absent you get:

Vite manifest not found at: /var/www/html/public/build/manifest.json

which is the single most common "works locally, 500s in Docker" failure.

Stage four — the runtime image

FROM base AS app

ARG UID=1000
ARG GID=1000

RUN addgroup -g ${GID} app \
    && adduser -u ${UID} -G app -h /var/www/html -s /bin/sh -D app

COPY --chown=app:app --from=vendor /var/www/html/vendor ./vendor
COPY --chown=app:app . .
COPY --chown=app:app --from=assets /app/public/build ./public/build

RUN mkdir -p storage/framework/cache/data \
             storage/framework/sessions \
             storage/framework/views \
             storage/logs \
    && chown -R app:app storage bootstrap/cache \
    && chmod -R ug+rwx storage bootstrap/cache

COPY docker/php/www.conf /usr/local/etc/php-fpm.d/zz-www.conf
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/entrypoint

USER app

EXPOSE 9000
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm", "--nodaemonize"]

The UID/GID build args exist for one reason: on Linux, bind-mounted files retain host ownership. If the container user is UID 82 (www-data on Alpine) and your host user is UID 1000, the application cannot write to storage/logs and Laravel throws:

The stream or file "/var/www/html/storage/logs/laravel.log" could not be opened
in append mode: Failed to open stream: Permission denied

Building with --build-arg UID=$(id -u) --build-arg GID=$(id -g) makes the container user match the host user and the problem evaporates. On macOS and Windows this is moot — the file-sharing layer remaps ownership — but the arg is harmless there.

docker/php/www.conf routes worker output into the container log stream, which is what docker logs actually reads:

[www]
listen = 9000
clear_env = no

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

catch_workers_output = yes
decorate_workers_output = no
access.log = /proc/self/fd/2
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /proc/self/fd/2

clear_env = no is mandatory. PHP-FPM wipes the environment for worker processes by default, so without it env('REDIS_HOST') returns null inside FPM even though docker exec … printenv shows the variable perfectly. That asymmetry is genuinely hard to debug from the inside.

Size the pool against the container's memory limit rather than copying defaults. A Laravel request typically resolves at 40–80 MB RSS per worker; pm.max_children = 20 against an 80 MB average needs roughly 1.6 GB of headroom. Setting max_children above what memory allows converts a traffic spike into an OOM kill, and the container simply disappears with exit code 137. pm.max_requests = 500 recycles workers periodically, capping the blast radius of any slow leak in a long-lived process.

Wiring Nginx to PHP-FPM

PHP-FPM speaks FastCGI, not HTTP, so something has to terminate HTTP and serve static files. Give Nginx its own tiny stage that carries a copy of public/:

FROM nginx:1.27-alpine AS web
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=app /var/www/html/public /var/www/html/public

docker/nginx/default.conf:

server {
    listen 8080;
    server_name _;
    root /var/www/html/public;
    index index.php;

    client_max_body_size 32m;
    sendfile off;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass app:9000;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_read_timeout 60s;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ \.php$ { return 404; }
    location ~ /\.(?!well-known).* { deny all; }
}

The critical constraint: SCRIPT_FILENAME is resolved inside the FPM container, not inside Nginx. Both containers must agree the document root is /var/www/html/public. When they disagree, Nginx returns a bare 404 and the FPM log says:

Primary script unknown

The location ~ \.php$ { return 404; } rule below the index handler blocks execution of any PHP file other than the front controller — a cheap, standard hardening step. sendfile off avoids a stale-file-serving quirk on bind-mounted volumes where Nginx caches file descriptors across edits.

The Compose file for local development

Compose v2 ignores the version: key and warns the attribute 'version' is obsolete, it will be ignored. Omit it. The full schema is documented in the Compose file reference.

services:
  app:
    build:
      context: .
      target: app
      args:
        UID: ${UID:-1000}
        GID: ${GID:-1000}
    env_file: .env
    environment:
      PHP_OPCACHE_VALIDATE_TIMESTAMPS: "1"
    volumes:
      - .:/var/www/html:cached
      - /var/www/html/vendor
      - ./docker/php/dev.ini:/usr/local/etc/php/conf.d/zz-dev.ini:ro
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

  web:
    build:
      context: .
      target: web
    ports:
      - "8000:8080"
    volumes:
      - ./public:/var/www/html/public:ro
    depends_on:
      - app

  mysql:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: ${DB_DATABASE:-laravel}
      MYSQL_USER: ${DB_USERNAME:-laravel}
      MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret}
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${DB_PASSWORD:-secret}"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s

  redis:
    image: redis:7.4-alpine
    command: ["redis-server", "--appendonly", "yes", "--save", ""]
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  mysql-data:
  redis-data:

Three things here repay close attention.

depends_on alone does not wait for readiness. The short-form list only orders container start, not service availability. MySQL 8.4 takes 15–25 seconds on a cold volume to initialise the data directory, and during that window it is listening but rejecting connections. The long form with condition: service_healthy blocks until the healthcheck passes, which is what actually prevents boot-order race conditions.

The anonymous volume - /var/www/html/vendor. The bind mount .:/var/www/html shadows everything the image built at that path, including vendor/. Declaring an anonymous volume at the nested path masks the bind mount for that subtree, so the image's vendor/ remains visible. Without it, the first request in dev fails with Failed to open stream: No such file or directory ... /var/www/html/vendor/autoload.php.

start_period tells Docker to ignore failing healthchecks during initial boot rather than counting them toward the retry budget, so a slow first-run database initialisation does not mark the service unhealthy.

docker/php/dev.ini flips the production assumptions:

opcache.validate_timestamps=1
opcache.revalidate_freq=0
display_errors=On
error_reporting=E_ALL

Without this, validate_timestamps=0 from the base image means every edit you make to a bind-mounted file is invisible to PHP until you restart the container. The symptom — "my change did nothing" — sends people hunting for a Laravel cache problem that does not exist.

Redis is configured with --appendonly yes --save "" so it persists via AOF rather than RDB snapshots, which avoids fork-induced latency spikes. If you use Redis for both cache and queue, use separate logical databases or a separate instance; flushing the cache should never flush pending jobs. The trade-offs between persistence modes are covered in the Redis persistence documentation.

Queue workers, scheduler, and Horizon as separate services

A queue worker is not a web request handler and must not share a container with one. Give it its own service reusing the same image:

  queue:
    build:
      context: .
      target: app
    command:
      - php
      - artisan
      - queue:work
      - --queue=high,default
      - --tries=3
      - --backoff=5,15,60
      - --max-time=3600
      - --max-jobs=500
    env_file: .env
    stop_signal: SIGTERM
    stop_grace_period: 60s
    restart: unless-stopped
    depends_on:
      redis:
        condition: service_healthy
    deploy:
      replicas: 2

  scheduler:
    build:
      context: .
      target: app
    command: ["php", "artisan", "schedule:work"]
    env_file: .env
    restart: unless-stopped

stop_signal: SIGTERM plus stop_grace_period: 60s is the graceful-shutdown contract. Laravel's worker traps SIGTERM, finishes the job in flight, and exits. If the grace period expires first, Docker sends SIGKILL and the job dies mid-execution — visible later as a job that ran twice, or a half-written database row. Sixty seconds should exceed your slowest job's runtime; measure it rather than guessing. The deployment implications are laid out in the Laravel queue worker documentation.

--max-time=3600 and --max-jobs=500 restart the worker process periodically, which matters because a worker is a long-lived PHP process that boots the framework once and keeps every singleton in memory for hours. Any accumulating state — a growing static array, an unclosed resource — eventually manifests as Allowed memory size of 134217728 bytes exhausted. Periodic recycling makes leaks a non-event.

schedule:work replaces cron entirely. It runs in the foreground and dispatches due tasks every minute, which is exactly what you want in a container: one process, foreground, logs to stdout, restartable. Do not install cron inside the image to achieve the same thing.

For Horizon, swap the worker service for command: ["php", "artisan", "horizon"] and set stop_signal: SIGTERM — Horizon translates that into a graceful pause-and-drain of all supervised workers.

The entrypoint: what runs at boot, not at build

#!/bin/sh
set -e

if [ "$1" = "php-fpm" ] || [ "$1" = "php" ]; then
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache

    if [ ! -L /var/www/html/public/storage ]; then
        php artisan storage:link
    fi
fi

exec "$@"

Every one of these caches must be built at container start, not at image build, because each one reads the environment. config:cache in particular serialises the fully resolved config array to disk — with DB_HOST, REDIS_HOST, APP_KEY and everything else frozen at whatever they were when the command ran.

The corollary trips up nearly everyone: once the config is cached, env() returns null everywhere outside config/ files. A Gate::define closure calling env('ADMIN_EMAIL'), a service provider reading env('STRIPE_KEY') — all silently become null in production and work fine in development, because development usually has no cached config. Publish the value through a config file and read it with config('services.stripe.key'). This constraint is called out directly in the Laravel deployment documentation.

exec "$@" matters mechanically: it replaces the shell with PHP-FPM so that FPM becomes PID 1 and receives signals directly. Without exec, the shell stays PID 1, SIGTERM never reaches FPM, and docker stop waits the full ten-second timeout before killing everything.

Migrations

Do not run php artisan migrate in the entrypoint of a service that scales past one replica. Three replicas starting simultaneously means three concurrent migration runs against the same schema, and the loser gets SQLSTATE[42S01]: Base table or view already exists.

Two safe options. Run it as a one-shot Compose job:

docker compose run --rm app php artisan migrate --force

Or, if it genuinely must live in the entrypoint, use the atomic lock:

php artisan migrate --force --isolated

--isolated acquires a lock through the cache driver; only one process runs the migrations and the others exit cleanly with status 0. It requires a shared cache driver — redis, memcached, dynamodb, or database. With file or array, each container has its own private cache and the lock is meaningless.

Building and shipping the production image

Production drops the bind mounts and the dev INI override, and pins a built image instead of building on the host:

services:
  app:
    image: registry.example.com/myapp:${APP_VERSION}
    env_file: .env.production
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php artisan tinker --execute='exit(0);' || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 1g

Laravel 11 and later expose an HTTP health endpoint at /up, registered in bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->create();

That gives the web container a meaningful check — curl -fsS http://localhost:8080/up — that exercises the whole Nginx → FPM → framework-boot path rather than just confirming a port is open.

Build for the target architecture explicitly. Building on an Apple Silicon machine and deploying to an x86-64 host produces this at container start:

exec /usr/local/bin/docker-php-entrypoint: exec format error

The image built fine, pushed fine, and is simply the wrong CPU architecture. Use buildx:

docker buildx build \
  --platform linux/amd64 \
  --target app \
  --build-arg UID=1000 --build-arg GID=1000 \
  --cache-from type=registry,ref=registry.example.com/myapp:buildcache \
  --cache-to   type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
  --tag registry.example.com/myapp:1.8.0 \
  --push .

The registry-backed cache is what makes CI builds fast. A fresh CI runner has no local layer cache, so without --cache-from every build is cold and composer install runs in full every time. With it, an application-code-only change reuses the vendor and asset layers straight from the registry.

Verify what you actually shipped:

docker image ls registry.example.com/myapp:1.8.0
docker history --no-trunc registry.example.com/myapp:1.8.0 | head -20
docker run --rm registry.example.com/myapp:1.8.0 sh -c 'which npm composer git || echo clean'

That last command should print clean. If it prints paths, a build tool leaked into the runtime stage.

Common pitfalls

Copying .env into the image. Even with a later RUN rm .env, the file lives forever in the layer that added it and docker save will hand it to anyone. Inject configuration through env_file, container environment, or a secrets manager.

config:cache at build time. Covered above, but it is worth restating because it produces the most confusing failures — an image that works in staging and fails in production with values from neither environment.

env() outside config files. After config:cache, it returns null. Search your codebase with grep -rn "env(" app/ routes/ database/ and move every hit into a config file.

127.0.0.1 in .env. Inside a container, 127.0.0.1 is the container itself. Use the Compose service name: DB_HOST=mysql, REDIS_HOST=redis. This one accounts for a large share of Connection refused reports.

The Vite dev server binding to localhost. Running npm run dev inside a container leaves it listening on the container's loopback, unreachable from the host browser. Set the host explicitly:

export default defineConfig({
    plugins: [laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true })],
    server: {
        host: '0.0.0.0',
        port: 5173,
        hmr: { host: 'localhost' },
    },
});

and publish 5173:5173. The hmr.host override is needed because the browser connects to the HMR websocket from outside, where the container's internal hostname does not resolve.

Stale public/hot. npm run dev writes public/hot; if that file survives into a production image, @vite points every asset at a dev server that does not exist, and the page loads with zero CSS. It is in the .dockerignore above for that reason.

Missing PDO driver. could not find driver means the extension was never installed. php -m | grep pdo inside the container settles it in one command.

Truncated FPM logs. PHP-FPM truncates each logged line at 1024 characters by default, so long stack traces arrive cut off mid-frame. Raise it with log_limit = 8192 in the global [global] section of php-fpm.conf.

Ignoring platform requirements. composer install --ignore-platform-reqs makes the build succeed and moves the failure to runtime, where it surfaces as a fatal Call to undefined function instead of a clear dependency error. Fix the extension list instead.

One container, many processes. Running FPM, the queue worker, and cron under supervisord in a single container means a crashed worker leaves the container "healthy", you cannot scale workers independently of web capacity, and docker logs becomes an interleaved mess. Separate services cost nothing extra — they share the same image layers.

Forgetting APP_KEY. Without it every encrypted cookie fails and Laravel throws No application encryption key has been specified. Generate it once with php artisan key:generate --show and inject it as an environment variable; never bake it into the image, and never regenerate it on a running system — all existing sessions and encrypted column values become undecryptable.

Where this leaves you

The result is an image containing PHP, its extensions, vendor/ without dev dependencies, compiled assets, and your code — nothing else. It rebuilds in seconds for the common case, refuses to leak secrets through layers, runs as a non-root user, and shuts down cleanly enough that in-flight jobs finish instead of vanishing.

The habit worth carrying forward is smaller than any individual technique: keep a hard line between build-time and run-time. Anything that reads the environment belongs at run time; anything that only reads your source belongs at build time. Nearly every Docker failure described above is a violation of that one boundary, and once you have it in mind the right structure mostly writes itself.

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

🎁 Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.