Distroless Images for PHP Applications
AI generated
FROM
RUN
Docker · PHP · Container Security · Image Building
Distroless Images for PHP Applications
minimal runtime, minimal attack surface

Distroless images contain neither a shell nor a package manager nor unnecessary system libraries, which drastically reduces the attack surface of a PHP container. With static-php-cli or FrankenPHP as a base, a complete PHP runtime can be compiled into a distroless image without needing the classic PHP-FPM distribution.

18 min read Distroless · Static PHP · FrankenPHP · Multi-Stage Docker · PHP 8.4 · Container Security

1. What distroless images are and why PHP teams need them

A distroless image contains only the application itself and its direct runtime dependencies, but no distribution in the classic sense. No apt, no apk, no shell, no coreutils package. Google maintains the best known distroless base image family under gcr.io/distroless, with variants for static binaries, for Java, for Node.js and for C libraries. For PHP there is no official distroless variant, because the classic PHP interpreter dynamically links against libphp, libxml2 and a dozen other shared libraries.

The appeal of distroless images lies in the drastically reduced attack surface. An attacker who achieves code execution through an application vulnerability finds neither bash nor curl nor a package manager in a distroless container to move laterally or download tools with. Vulnerability scanners such as Trivy or Grype often report only a handful of CVEs for a typical distroless image, while a comparable Debian slim image with a full base system easily reaches triple digits. For PHP applications in production, especially in regulated industries, that is an argument that goes well beyond image size alone.

The core question is therefore how to get a PHP runtime into a distroless image when PHP traditionally links dynamically and expects a shell for entrypoint scripts. The answer lies in statically compiled PHP that no longer needs external shared libraries, combined with a multi-stage build that handles compilation in a build stage and copies only the finished binary into the distroless runtime stage.

2. The challenge: PHP actually needs a runtime

A standard PHP container is based on php:8.4-fpm or php:8.4-cli, both Debian based images with a full base system. The PHP interpreter itself is dynamically linked against libc, libxml2, libssl, libcurl and other libraries, and many extensions such as pdo_mysql, gd or intl bring their own native dependencies. A distroless image without these libraries would let the interpreter fail immediately at startup with a linker error, because the required shared object files are missing.

A second problem concerns entrypoint scripts. Many PHP containers use a docker-entrypoint.sh that checks environment variables, generates configuration files, or waits for a database before starting. Such scripts absolutely require a shell, which by definition is missing in a real distroless image. Teams switching to distroless must either move this logic into the application itself, ship it as a compiled init binary, or delegate it to an orchestrator such as Kubernetes with init containers running in a separate image that does contain a shell.

These two hurdles, dynamic linking and shell dependency, are why distroless images are seen less often for PHP in practice than for Go or Java. They are solvable, though, once the build process is consistently switched to static compilation and orchestration logic is kept out of the container startup path.

3. static-php-cli: a PHP binary without external dependencies

The static-php-cli project compiles PHP, including common extensions, into a single static binary that no longer needs external shared libraries. All dependencies, from libxml2 to openssl, are statically embedded into the binary at build time. The result is a PHP binary that runs inside a distroless image without a single additional system library, because it simply no longer contains dynamic references.

The compilation process runs in a separate build container with full toolchain access, compiler, header files and build dependencies for each extension. For Magento typical extensions such as gd, intl, bcmath, soap and opcache, static-php-cli now fully supports static linking, whereas extensions with complex external dependencies like imagick require more configuration effort.


# Stage 1: compile a fully static PHP binary with static-php-cli
FROM ubuntu:24.04 AS builder

RUN apt-get update && apt-get install -y \
    curl php-cli php-xml php-curl unzip git build-essential \
    autoconf bison re2c pkg-config libsqlite3-dev

WORKDIR /build
RUN curl -sSL https://dl.static-php.dev/static-php-cli/spc-linux-x86_64.tar.gz \
    | tar -xz && mv spc-linux-x86_64 spc

# Download required source packages
RUN ./spc download --for-extensions="bcmath,gd,intl,opcache,pdo_mysql,soap,zip" \
    --with-php=8.4

# Build a static CLI binary with the extensions our app needs
RUN ./spc build "bcmath,gd,intl,opcache,pdo_mysql,soap,zip" \
    --build-cli --build-micro

# Stage 2: distroless runtime with only the static binary
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=builder /build/buildroot/bin/php /usr/local/bin/php
COPY --chown=nonroot:nonroot ./src /app
WORKDIR /app
USER nonroot
ENTRYPOINT ["php", "/app/bin/console"]

This two-stage layout cleanly separates the build environment from the runtime environment. Anything may be installed in the build stage, because only the end result, the static binary, is carried over into the distroless image. The runtime stage stays minimal, without a compiler, without a package manager, without a shell, running under the non-privileged default user from the nonroot variant.

5. FrankenPHP as an alternative to PHP-FPM plus Nginx

FrankenPHP takes a different approach to distroless images: it compiles PHP as a module directly into a Go based web server, so a separate PHP-FPM process group and a separate Nginx container are no longer needed. The resulting server is a single, statically compilable Go binary with an embedded PHP runtime, which architecturally puts it much closer to classic distroless candidates such as Go applications than the classic PHP-FPM stack.

For a distroless image with FrankenPHP, there is also no need to configure a Unix socket between Nginx and PHP-FPM, which regularly causes permission issues on minimal base images. FrankenPHP terminates HTTP directly, supports HTTP/2 and HTTP/3 natively, and thereby reduces the number of processes in the container from two, FPM and web server, down to a single one.


# Stage 1: build FrankenPHP binary statically with the app embedded
FROM dunglas/frankenphp:static-builder AS builder
WORKDIR /go/src/app
COPY ./src /app/public
RUN EMBED=/app ./build-static.sh

# Stage 2: distroless-style runtime, single self-contained binary
FROM gcr.io/distroless/base-debian12:nonroot AS runtime
COPY --from=builder /go/src/app/frankenphp-linux-x86_64 /frankenphp
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/frankenphp", "php-server", "--listen", ":8080"]

4. Multi-stage Dockerfile layout for distroless runtimes

The key to every distroless image is the consistent separation of build and runtime stages inside the Dockerfile. Composer installation, asset compilation, static analysis and compiling the PHP binary all belong exclusively in build stages with a full toolset. Only artifacts that are actually needed at runtime are copied via COPY --from=builder into the final stage.

A common mistake when switching to distroless images is accidentally copying forgotten debug tools or Composer cache directories into the runtime stage, for example through a too broad COPY . . instead of an explicit artifact list. A .dockerignore with explicit exclusions for vendor/bin, test directories and development tools prevents unnecessary files from sneaking back into the minimal image and undermining the point of distroless.

6. Debugging without a shell: ephemeral debug containers

The most obvious downside of distroless images is the absence of sh, bash, ls or cat inside the running container. A classic docker exec -it container sh fails because there is no shell for exec to attach to. For Kubernetes environments, kubectl debug with an ephemeral container solves the problem elegantly: a temporary debug container with a full toolset is attached to the namespace of the target pod, without modifying the running distroless container itself.

For plain Docker without Kubernetes, docker debug from Docker Desktop offers a similar approach, temporarily layering a shell over a running container without having to rebuild the image itself. It is important to never leave these debug tools permanently inside the production distroless image, but to add them only temporarily and on demand.


# Attach an ephemeral debug container to a running distroless pod
kubectl debug -it app-pod-7d9f8 \
  --image=busybox:1.36 \
  --target=php-app \
  -- sh

# Inspect the process namespace shared with the distroless container
ps aux
cat /proc/1/root/app/composer.lock

# Docker Desktop equivalent for a locally running distroless container
docker debug php-distroless-container

7. Health checks and process management without shell tools

A classic Docker HEALTHCHECK with CMD curl -f http://localhost/health || exit 1 does not work in a distroless image, because neither curl nor a shell is available to evaluate the ||. The solution is a dedicated, statically compiled health check binary that runs directly as a process and signals success or failure through its exit code, entirely without shell logic.

For Magento or Symfony applications running FrankenPHP, a small PHP script executed directly via php health-check.php as the HEALTHCHECK CMD is often enough, because the PHP binary itself acts as the interpreter and needs no external shell. Process management under Kubernetes is handled by the kubelet through liveness and readiness probes anyway, so a distroless image needs no additional init logic inside the container itself as long as the probes are configured correctly.


# Healthcheck without shell: run the static PHP binary directly
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD ["/usr/local/bin/php", "/app/bin/health-check.php"]

8. CI integration: build, scan, sign

In the CI pipeline it pays off to systematically check distroless images with a vulnerability scanner such as Trivy before they are pushed to the registry, to document the expected CVE reduction as a measurable metric. A comparison run against the previous Debian slim image regularly shows a reduction of reported CVEs by eighty to ninety five percent in practice, because virtually the entire operating system package ecosystem disappears.

It is also worth signing the built distroless image before deployment, for example with Cosign, to make sure that only images that actually originate from your own pipeline end up in production. This combination of minimal attack surface and cryptographically verified provenance results in a considerably more robust supply chain than the classic approach with a full base image and no signature.


# GitLab CI: build distroless image, scan, then sign
build-distroless:
  stage: build
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
    - trivy image --exit-code 1 --severity CRITICAL,HIGH "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - cosign sign --key cosign.key "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"

9. Distroless compared to Debian slim and Alpine

The choice between distroless, Debian slim and Alpine for PHP workloads depends heavily on how much control over live debugging capability matters compared to the priority of a minimal attack surface. The following table compares the three approaches for typical PHP production applications.

Criterion Debian Slim Alpine Distroless
Shell in the container Yes, bash Yes, ash No
Package manager apt present apk present None
Typical CVE count Medium to high Low to medium Very low
Build effort for PHP Low Medium, musl compatibility High, static PHP required
Debugging live container Direct access Direct access Only via ephemeral container

For teams already using ephemeral debug containers in Kubernetes in production, the security benefit of a distroless image clearly outweighs the extra debugging effort. For smaller teams without Kubernetes orchestration, a well maintained Debian slim image with regular security updates is often the more pragmatic compromise between effort and benefit.

Mironsoft

Container security and Docker build strategies for PHP applications

Distroless images for your PHP production stack?

We build multi-stage Dockerfiles with static PHP or FrankenPHP, set up CI scans with Trivy and image signing with Cosign, and guide the move to distroless runtime images without operational risk.

Static PHP builds

Compiling your PHP extensions as a static binary for distroless runtimes

Dockerfile rework

Multi-stage builds with a clean separation between build and runtime stage

CI security

Anchoring Trivy scans, Cosign signatures and SBOM generation in the pipeline

10. Summary

Distroless images for PHP applications reduce a container's attack surface to a minimum, because there is neither a shell nor a package manager nor unnecessary system libraries present. The path there leads through statically compiled PHP with static-php-cli or through FrankenPHP as an embedded Go binary, each built in a separate compiler stage and copied into the distroless runtime only as a finished artifact. Debugging shifts from direct shell access to ephemeral debug containers, and health checks run through compiled binaries instead of shell scripts using curl.

The switch is especially worthwhile for regulated environments and for teams that must present CVE counts as a hard metric in security audits. Teams already using Kubernetes with ephemeral containers lose practically no debugging capability by moving to distroless images, while gaining a massively reduced attack surface and a much smaller number of reported vulnerabilities per image.

Distroless Images for PHP Applications — Key Takeaways

static-php-cli

Compiles PHP with extensions into a static binary without external shared libraries, a prerequisite for a real distroless image.

FrankenPHP

Replaces PHP-FPM plus Nginx with a single Go binary featuring an embedded PHP runtime, ideal for minimal containers.

Debugging without a shell

Ephemeral debug containers in Kubernetes or docker debug instead of exec -it container sh.

CVE reduction

Typically eighty to ninety five percent fewer reported CVEs compared to Debian slim, since the base operating system disappears.

11. FAQ: Distroless Images for PHP Applications

1What is a distroless image?
Only the application and its direct runtime dependencies, without a shell, package manager or full operating system ecosystem.
2Why is PHP harder than Go?
PHP links dynamically against many shared libraries. static-php-cli solves this with fully static compilation.
3What is static-php-cli?
A project that compiles PHP with extensions into a single static binary without external dependencies.
4Can Composer still be used?
Yes, in the build stage. Only the vendor directory and binary are copied into the final distroless stage.
5How do I debug a running container?
With kubectl debug or docker debug, each via a temporary ephemeral debug container.
6Does curl work in HEALTHCHECK?
No. Health check runs as a compiled binary or directly as a PHP script through the interpreter.
7What is FrankenPHP?
Replaces PHP-FPM and Nginx with a single Go binary featuring an embedded PHP runtime.
8How much smaller is the image?
Size is often sixty to eighty percent smaller, CVEs typically reduced by eighty to ninety five percent.
9Do I need Kubernetes for this?
No, works with plain Docker too. Docker Desktop offers a comparable feature with docker debug.
10Which extensions are most effort?
Extensions with complex external dependencies like imagick, unlike directly supported ones such as bcmath, gd, intl or pdo_mysql.