Safeguard
Containers

Docker Laravel Security: Hardening Your PHP Container from Base Image to Runtime

A security-focused guide to running Laravel in Docker — non-root PHP-FPM, multi-stage builds, secret handling, and locking down the layers that leak.

Marcus Chen
DevSecOps Engineer
6 min read

A secure Docker Laravel deployment comes down to four things: a minimal, pinned base image; a multi-stage build that keeps Composer and dev tooling out of the runtime layer; a .env file that never gets baked into an image; and a container that runs PHP-FPM as a non-root user. Laravel is one of the harder frameworks to containerize cleanly because PHP's interpreter and extensions are runtime dependencies, not build-time ones — you can't compile everything away into a single static binary. This guide walks through the specific hardening steps that matter for a Docker Laravel image.

Why is Docker Laravel harder to secure than a Go or Node image?

With Go, the final stage can be FROM scratch with nothing but a static binary. With Laravel you're stuck shipping the PHP runtime, the extensions your app needs (pdo, mbstring, bcmath, intl, and often GD or Imagick), and a process manager. That's a bigger attack surface by design, so the goal isn't a tiny image — it's a tight one, with nothing present that you don't run.

The layered nature of Docker also means mistakes are sticky: a secret written into layer three and "deleted" in layer seven is still recoverable from the layer tarball with docker history or by unpacking the image. Laravel apps handle a .env full of database credentials, app keys, and API tokens, so this is exactly the kind of thing that leaks if you're careless with COPY.

Start from a pinned, minimal base image

Don't reach for the full php:8.3 image and its default toolchain. Prefer a slim variant and pin it by digest so a mutable tag can't silently change what you ship:

# pin by digest, not a floating tag
FROM php:8.3-fpm-alpine@sha256:...

Alpine keeps the image small, though be aware some PHP extensions need extra build packages or behave differently against musl libc — test Imagick and any native extensions before committing to Alpine. If an extension fights you, a -slim Debian base is a reasonable fallback; the security win is choosing the smallest base that still runs your extensions, not chasing the absolute minimum at the cost of a broken build.

Rebuild on a schedule (weekly, not just on code changes) so you inherit upstream PHP and OS security patches even when your own code hasn't moved.

Use a multi-stage build to keep Composer out of production

Composer, its cache, and any Node build tooling for your frontend assets have no business in the runtime image. Split the build:

# --- build stage ---
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

# --- runtime stage ---
FROM php:8.3-fpm-alpine
WORKDIR /var/www
COPY --from=vendor /app/vendor ./vendor
COPY . .

The --no-dev flag is the important one: it drops development-only packages (test frameworks, debug tooling, fakers) that carry their own vulnerabilities and have no reason to run in production. --optimize-autoloader is both a performance and a hygiene win. The runtime stage never sees Composer itself.

Never bake .env into the image

This is the single most common Docker Laravel mistake. A .env copied in during build is permanently embedded in a layer. Handle it structurally:

  • Add .env, .git, and storage/log paths to a .dockerignore so they can't be swept in by a COPY . ..
  • Inject configuration at runtime through environment variables or an orchestrator secret (Docker/Kubernetes secrets), not into the image.
  • For build-time secrets you genuinely can't avoid (a private Composer repo token, say), use BuildKit's --mount=type=secret so the credential is available during the build but never written to a layer.
# .dockerignore
.env
.env.*
.git
storage/logs
node_modules
tests

Run Laravel's config cache (php artisan config:cache) at container start with the real environment already injected, rather than caching a config that captured build-time placeholder values.

Run PHP-FPM as a non-root user

By default, containers run as root, which means an application-level bug — a file-write vulnerability, a deserialization flaw, a path traversal in an upload handler — hands an attacker root inside the container. Drop that:

RUN addgroup -g 1000 www && adduser -u 1000 -G www -S -D www
# ensure only the paths Laravel writes to are writable
RUN chown -R www:www /var/www/storage /var/www/bootstrap/cache
USER www

Laravel only needs write access to storage/ and bootstrap/cache/; everything else can be read-only. In Kubernetes, reinforce this in the pod spec with runAsNonRoot: true, readOnlyRootFilesystem: true, and explicit writable volumes for those two directories — the same non-root discipline that applies to every container image, covered in more depth in these Docker image security best practices.

Scan the image and its dependencies before it ships

A Docker Laravel image carries two distinct risk surfaces: OS packages in the base layer and PHP packages in vendor/. Scan both, in CI, before merge:

  • Scan the built image for OS-level CVEs so a stale base doesn't ship a known-vulnerable library.
  • Run software composition analysis against composer.lock to catch vulnerable PHP dependencies — including transitive ones you never required directly. An SCA tool such as Safeguard can flag that a Composer dependency pulled in three levels deep has a known advisory and tell you the minimum safe version to bump to.
  • Generate an SBOM at build time so "what PHP packages were in the image we shipped last month" is a lookup, not an incident-time investigation.

Gate the pipeline on new critical findings with an available fix, and re-scan images already sitting in your registry — a Laravel image that was clean at build can accumulate newly disclosed CVEs while it waits to be redeployed.

FAQ

Should a Docker Laravel image use Alpine or Debian slim?

Alpine gives the smallest image but uses musl libc, which can trip up native PHP extensions like Imagick. Test your full extension set on Alpine first; if something breaks, -slim Debian is a safe, only-slightly-larger fallback.

How do I keep my .env out of the Docker image?

Add it to .dockerignore, inject configuration as runtime environment variables or orchestrator secrets, and use BuildKit secret mounts for any build-time credentials. Never COPY a real .env into a layer.

Do I need Nginx in the same container as PHP-FPM?

Usually no. The common pattern is PHP-FPM in one container and Nginx in a separate container (or a sidecar), each doing one job. Keeping them separate makes each image smaller and easier to reason about and scan.

How do I run Laravel as non-root without breaking file writes?

Create a dedicated user, then chown only storage/ and bootstrap/cache/ to it and set USER to that account. Laravel doesn't need write access anywhere else, so a read-only root filesystem with those two writable paths works cleanly.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.