Docker Container Security: Images, Runtime, Secrets
AI generated
OWASP
0x00
Security · Docker · Container · DevSecOps
Docker Container Security: Images, Runtime, Secrets
From root containers to a hardened production setup

Containers share the host kernel, so they are not an automatic security win over classic servers. This article shows how minimal base images, non-root users, automated vulnerability scanning in the CI pipeline, secure secrets handling without image layer leaks, and a hardened runtime with a read-only filesystem and reduced Linux capabilities make Docker deployments for Magento and PHP projects noticeably safer.

17 min. read Trivy · BuildKit Secrets · Capabilities Docker · Kubernetes · Magento 2.4.8

1. Why container security differs from classic server security

A widespread misconception is that containers are isolated by nature, the way a virtual machine is. In reality, every container on a host shares the same Linux kernel. Isolation comes from namespaces and cgroups, not from separate hardware virtualization. A kernel exploit or a misconfigured capability can therefore potentially break out of a container and compromise the host or other containers. This shared attack surface is what fundamentally distinguishes container security from classic server hardening, where every VM owns its own kernel.

For Magento and PHP deployments, this means the security of a container does not depend on the application code alone, but equally on the base image, the build pipeline, and the runtime configuration. A production php-fpm container running as root, built on an outdated Debian base image with unrestricted capabilities, is a significant risk even with flawless application code. The four layers, image, build, secrets, and runtime, therefore need to be reviewed and hardened individually instead of relying on the assumption that "containers are isolated anyway".

The CIS Docker Benchmark and the OWASP Docker Security Cheat Sheet summarize these layers systematically and serve as a reference for automated audits, for example with tools like docker-bench-security. Teams that work through these checklists manually once instead of wiring them into the CI pipeline lose that protection the moment the base image is next updated.

2. Minimal base images: Alpine, distroless, and multi-stage builds

Every additional package in a base image is potential attack surface: an unused shell, a package manager, outdated libraries with known CVEs. A full ubuntu or debian image ships hundreds of packages, of which a web application usually needs only a handful. alpine-based images cut the attack surface down to a few megabytes, since they use musl libc and BusyBox instead of a full GNU userland. For PHP applications with many compiled extensions, Alpine can be somewhat more involved to build because of the different libc, but it offers the smallest practical attack surface.

Distroless images from Google go a step further: they contain only the application and its runtime dependencies, with no shell, package manager, or coreutils at all. An attacker who achieves remote code execution finds no sh or curl inside a distroless container to run follow-up actions such as downloading additional payloads. The downside is that debugging a running container becomes practically impossible, which is why distroless usually only makes sense for the final production layer.

Multi-stage builds are the key to making both approaches practical: Composer installation, asset compilation, and build tooling run in a build stage with the full toolset, while only the finished artifacts get copied into a minimal runtime image. That way, build dependencies like git, a compiler, or Composer itself never end up in the final production image.


# Dockerfile: hardened multi-stage build for a PHP/Magento application
# Stage 1: build stage with full toolset, never shipped to production
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# Install dependencies without dev packages and without running scripts yet
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

FROM php:8.4-fpm-alpine AS app
WORKDIR /var/www/html

# Install only the runtime extensions actually required, then remove build tools
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
    && docker-php-ext-install intl pdo_mysql opcache \
    && apk del .build-deps

COPY --from=vendor /app/vendor ./vendor
COPY . .
RUN composer dump-autoload --optimize --no-dev

# Create a dedicated non-root user instead of running as root
RUN addgroup -g 1000 appuser \
    && adduser -D -u 1000 -G appuser appuser \
    && chown -R appuser:appuser /var/www/html

USER appuser
EXPOSE 9000
CMD ["php-fpm", "-F"]

3. Never run as root: the USER directive and rootless runtimes

Without an explicit USER directive, a container process runs as root by default, with UID 0. Within the container namespace this is initially "just" the container's own root, but a kernel bug or a misconfigured volume mount can cause UID 0 inside the container to coincide with UID 0 on the host. An attacker who achieves code execution through an application vulnerability has far more room for privilege escalation as root inside the container than a process that runs with an unprivileged UID from the start.

The fix is unglamorous but effective: create a dedicated user with a fixed UID/GID in the Dockerfile, assign ownership of all required files to that user, and switch to it via the USER directive before the CMD/ENTRYPOINT. It matters to set the UID explicitly (for example 1000) rather than letting the system pick one, so it stays predictable in Kubernetes securityContext rules or volume permissions. Ports below 1024 cannot be bound by a non-root process, which is why production PHP-FPM or Node containers typically listen on ports like 8080 or 9000, with an upstream reverse proxy handling port termination.

At the host level, rootless Docker or Podman adds another layer of protection: the container daemon itself runs without root privileges and maps container UIDs to unprivileged host UIDs via user namespaces. Even if an attacker becomes root inside the container, they land in an unprivileged UID range on the host with no access to critical system resources. Podman supports rootless operation natively; for Docker, an official rootless mode has been available since version 20.10.

4. Image scanning in the CI pipeline with Trivy and Grype

Even a carefully built image inherits vulnerabilities from its base layers and installed packages, and new CVEs are published daily, including for packages that were considered safe at build time. Trivy (Aqua Security) and Grype (Anchore) are open-source scanners that check a finished image against current CVE databases, covering both OS packages and application dependencies such as Composer or npm packages. Both tools integrate into any CI pipeline as a simple CLI call, without requiring a separate server component.

The important part is not merely logging the scan results, but actively failing the build on critical findings. Trivy supports an --exit-code parameter combined with --severity, so only HIGH and CRITICAL findings trigger a pipeline failure, while lower severities are simply recorded. This tiering prevents alert fatigue from hundreds of low-severity findings and keeps attention on vulnerabilities that are actually exploitable.

An often overlooked point: scanning needs to happen repeatedly, not just at build time. An image that passes cleanly today can become vulnerable in four weeks because of newly published CVEs, even though nothing about the image itself has changed. Registries like Harbor or AWS ECR therefore offer continuous re-scanning of already pushed images, and Dependabot-style tools can automatically open pull requests for updated base images.


# CI pipeline step: scan an image and fail the build on HIGH/CRITICAL findings

# Trivy: scan the built image, exit with code 1 on high or critical CVEs
trivy image \
  --severity HIGH,CRITICAL \
  --exit-code 1 \
  --ignore-unfixed \
  --format table \
  registry.example.com/mironsoft/magento-app:${CI_COMMIT_SHA}

# Grype: equivalent scan with a fail-build threshold
grype registry.example.com/mironsoft/magento-app:${CI_COMMIT_SHA} \
  --fail-on high \
  --only-fixed

# Export a machine-readable report for later triage and auditing
trivy image \
  --format json \
  --output trivy-report.json \
  registry.example.com/mironsoft/magento-app:${CI_COMMIT_SHA}

5. Why secrets baked into image layers never truly disappear

A classic mistake: a database password or API token gets passed via an ENV directive or as an ARG in a RUN command, for example to pull Composer packages from a private repository during the build. Even if a later instruction deletes the file that contained the secret, the value remains permanently in that image layer, because Docker layers are immutable diffs. Anyone with access to the image can extract the value with a single command, regardless of whether the file still exists in the final filesystem.

docker history --no-trunc shows every build layer, including the executed commands in plain text, and docker save followed by unpacking the resulting tar archive exposes every individual layer, including files deleted in earlier layers. A secret passed via ARG DB_PASSWORD effectively ends up in the image, even if it is never visible as an environment variable in the running container. This risk is not limited to public registries: even an internally hosted image can be inspected by anyone with pull access to the registry, which quickly becomes a broad attack surface in larger teams.

The practical consequence: secrets must never enter the build or runtime context via ENV, ARG, or a file that remains in the image. Instead, what is needed are mechanisms that make a secret available only temporarily during the build or at container start, without leaving it behind in a persistent layer.

6. BuildKit secrets and secure runtime secret injection

Docker BuildKit solves the build-time problem with dedicated secret mounts. Using --secret id=npmrc,src=./secret_file on the docker build call together with RUN --mount=type=secret,id=npmrc in the Dockerfile mounts the secret in a temporary, non-persistent filesystem, only for the duration of that single RUN instruction. It ends up neither in the build history nor in an image layer, because BuildKit explicitly excludes the mount contents from the layer diff. This is the correct way to provide, for example, private Composer repository credentials during composer install.

Runtime secrets, meaning database credentials, API keys, or JWT signing keys, follow a different rule: they generally do not belong in the image at all, and are injected only at container start. Docker Swarm and Kubernetes offer native secret objects for this, mounted into the container as temporary, RAM-backed files, typically under /run/secrets/. In production Kubernetes clusters, external secret managers such as HashiCorp Vault or AWS Secrets Manager complement this mechanism with rotation, audit logging, and fine-grained access control.

A practical rule of thumb for teams without a full secret-manager setup: environment variables set at runtime by the orchestration platform (not baked into the image) are acceptable, as long as they don't end up in logs or error output. What is never acceptable is a secret sitting as an ENV line in the Dockerfile or baked into a committed .env file that then gets copied into the image via COPY.


# BuildKit secret mount: use a credential at build time without leaking it into a layer

# Enable BuildKit explicitly for this build
export DOCKER_BUILDKIT=1

# Pass the secret file reference on the CLI, it is never written into the build context
docker build \
  --secret id=composer_auth,src=./auth.json \
  -t registry.example.com/mironsoft/magento-app:latest .

# Dockerfile excerpt: mount the secret only for the duration of this RUN instruction
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./

# The secret is mounted at /run/secrets/composer_auth and never persisted in a layer
RUN --mount=type=secret,id=composer_auth,target=/root/.composer/auth.json \
    composer install --no-dev --no-scripts --prefer-dist

7. Read-only root filesystems and tmpfs for writable paths

Most production application containers do not need to modify files inside the container filesystem after deployment. Configuration comes through environment variables or mounted ConfigMaps, and code is baked in at build time. A container that runs read-only at runtime (read_only: true or --read-only) blocks an entire class of post-exploitation techniques: an attacker trying to plant code through an application flaw cannot write a web shell to disk, download an additional binary, or tamper with log files to cover their tracks.

In practice, a handful of directories still need write access, such as PHP sessions, temporary upload files, or cache directories like Magento's var/. These paths are provided specifically as tmpfs mounts: an in-memory filesystem that gets wiped automatically on container restart and never touches the host's persistent disk. This combination, a read-only root filesystem plus targeted tmpfs exceptions, is the safest practical configuration for stateless application containers, and it is natively supported by both Docker Compose and Kubernetes securityContext rules.

8. Dropping Linux capabilities and enforcing no-new-privileges

Linux capabilities break the traditionally binary root/non-root distinction into granular rights such as CAP_NET_BIND_SERVICE (bind ports below 1024), CAP_SYS_ADMIN (broad system administration), or CAP_CHOWN (change file ownership). Docker grants a default set of around 14 capabilities to every container, of which most web applications actually need none. The safe baseline rule is therefore to drop all capabilities by default (cap_drop: ALL) and only add back the ones explicitly required (cap_add), instead of relying on Docker's defaults.

Complementing this, no-new-privileges: true (or security_opt: no-new-privileges:true) prevents a process inside the container from gaining additional privileges at runtime through setuid binaries or similar mechanisms, even if an exploitable setuid file happened to be present in the image. This setting is a kernel flag and works independently of the capability configuration as an additional layer of defense. Combined with a read-only filesystem, a non-root user, and minimal capabilities, the resulting container gives an attacker very limited room to maneuver even after a successful application compromise.


# docker-compose.yaml: hardened runtime configuration for a production PHP service
services:
  php-fpm:
    image: registry.example.com/mironsoft/magento-app:latest
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    tmpfs:
      - /var/www/html/var/cache:size=256m,mode=1777
      - /var/www/html/var/session:size=64m,mode=1777
      - /tmp:size=64m,mode=1777
    environment:
      - APP_ENV=production
    networks:
      - internal
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

{
  "SchemaVersion": 2,
  "ArtifactName": "registry.example.com/mironsoft/magento-app:1.4.2",
  "ArtifactType": "container_image",
  "Results": [
    {
      "Target": "magento-app (alpine 3.19.1)",
      "Class": "os-pkgs",
      "Type": "alpine",
      "Vulnerabilities": [
        {
          "VulnerabilityID": "CVE-2024-9143",
          "PkgName": "openssl",
          "InstalledVersion": "3.1.4-r5",
          "FixedVersion": "3.1.5-r0",
          "Severity": "HIGH",
          "Title": "openssl: Low-level GF(2^m) elliptic curve API out-of-bounds write",
          "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-9143"
        },
        {
          "VulnerabilityID": "CVE-2023-45853",
          "PkgName": "zlib",
          "InstalledVersion": "1.3-r0",
          "FixedVersion": "1.3.1-r0",
          "Severity": "CRITICAL",
          "Title": "zlib: integer overflow in MiniZip leading to heap buffer overflow",
          "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-45853"
        }
      ]
    }
  ]
}

9. Insecure vs. secure: Docker patterns compared

The patterns below show up in countless production Docker setups, even though each one has a well-documented, established hardening step. The table lines up the common insecure default against the secure alternative.

Area Insecure pattern Secure pattern
Base image Full debian/ubuntu image without version pinning Alpine or distroless with a fixed tag/digest
Process user No USER, process runs as root (UID 0) USER with a fixed non-root UID in the Dockerfile
Secrets ARG/ENV with credentials in the Dockerfile BuildKit --mount=type=secret, runtime injection
Filesystem Fully writable root filesystem read_only: true plus targeted tmpfs mounts
Capabilities Docker default set of ~14 capabilities active cap_drop: ALL, only required cap_add entries
Vulnerability checks No scanning, or only manual on demand Trivy/Grype in CI with fail-on-critical

Notably, none of these secure patterns add meaningful operational overhead once running. The entire effort sits in configuring the Dockerfile, CI pipeline, and Compose or Kubernetes manifest once. Turning these six points into a checklist baked into a Dockerfile template and a CI pipeline template gives every new project the hardening automatically.

Mironsoft

Docker and container security for Magento and PHP projects

Ready to harden your container setup?

We review your Dockerfiles, CI pipelines, and Compose or Kubernetes manifests for base images, root processes, secrets handling, and runtime hardening, then implement the fixes in a structured way.

Container security audit

Dockerfile review, CIS benchmark check, vulnerability scan setup

Secrets management

BuildKit secrets, Vault integration, rotation without downtime

CI/CD hardening

Trivy/Grype integration with fail-on-critical in your pipeline

10. Summary

Docker container security is not a single switch, but the consistent hardening of four connected layers: a minimal base image (Alpine or distroless) reduces the attack surface from the start, an explicit non-root user prevents an application flaw from directly turning into root privileges inside the container, automated scanning with Trivy or Grype in the CI pipeline catches known CVEs before deployment, and a hardened runtime configuration with a read-only filesystem and minimal Linux capabilities limits the damage if a vulnerability gets exploited anyway.

The most important mistake to avoid: once a secret has landed in an image layer, it cannot be safely removed after the fact. The only reliable protection is to keep it out of a layer from the start, using BuildKit secret mounts or runtime injection. Teams that bake these five building blocks, a minimal image, a non-root user, continuous scanning, layer-free secrets, and runtime hardening, into a reusable Dockerfile and pipeline template reach a solid security baseline for every new project without having to rethink it on every deployment.

Docker Container Security - The Essentials at a Glance

Minimal images

Alpine or distroless instead of full distributions, build tools kept out of the runtime image via multi-stage builds.

Non-root user

Fixed UID/GID via the USER directive, rootless Docker/Podman at the host level as an extra layer of protection.

Scanning & secrets

Trivy/Grype with fail-on-critical in CI, secrets handled exclusively via BuildKit mounts or runtime injection.

Runtime hardening

read_only: true with tmpfs exceptions, cap_drop: ALL, and no-new-privileges:true.

11. FAQ: Docker Container Security

1Why aren't containers automatically securely isolated?
Containers share the host kernel and separate processes via namespaces and cgroups, not dedicated hardware virtualization. A kernel exploit can therefore potentially break out of a container.
2What is the advantage of Alpine or distroless images?
Far fewer packages, sometimes with no shell or package manager at all. That reduces both potential CVEs and the tools available to an attacker after code execution.
3Why should a container never run as root?
UID 0 inside the container can lead to root privileges on the host given a kernel bug or a misconfigured volume. A fixed non-root UID via the USER directive structurally reduces this risk.
4What do tools like Trivy and Grype actually check?
They compare OS packages and application dependencies against current CVE databases and report severity plus, if available, the version that contains the fix.
5Why does a secret set via ENV remain in the image?
Docker layers are immutable diffs. A value stays in the layer even if a later instruction deletes the associated file, and can be extracted with docker history.
6How do BuildKit secrets differ from ARG or ENV?
BuildKit mounts a secret into a temporary filesystem only for the duration of a single RUN instruction. The value ends up in neither the build history nor an image layer.
7What does a read-only root filesystem actually achieve?
Prevents an attacker from writing a web shell to disk or downloading additional binaries after code execution. Required writable paths are provided specifically via tmpfs mounts.
8What are Linux capabilities and why should they be dropped?
They split root privileges into granular rights such as CAP_NET_BIND_SERVICE. Docker grants around 14 capabilities by default, most unused. cap_drop: ALL plus targeted cap_add minimizes the rights.
9What does the no-new-privileges option do?
Prevents a process from gaining additional privileges at runtime via setuid binaries. A kernel flag that works independently of the capability configuration.
10Is it enough to run image scanning once at build time?
No. New CVEs appear continuously, so a clean image today can be vulnerable within weeks. Continuous re-scanning of already pushed images in the registry is necessary.