A Hardened Image

Running as a non-root user on a read-only root filesystem, with var/ writable and no build tools left in the final image.

SyntaxUSER www-data

An image is a filesystem you ship, reachable by whatever runs inside the container. Hardening it means leaving less in it and giving the process less power.

Not root

By default the process in a container runs as root. Add USER www-data, or a user you create, as the last instruction of the final stage - after the COPY instructions, so ownership is set deliberately. A process that cannot write to /app cannot rewrite your code if something gets through.

Read-only root filesystem, writable var/

Start the container with its root filesystem mounted read-only, then mount the few paths that must be writable. Symfony writes the compiled container, compiled templates, logs and the filesystem cache under var/; everything else can be immutable. If var/ cannot be a mount, override getCacheDir() and getLogDir() on the kernel, or set APP_CACHE_DIR and APP_LOG_DIR - those variables are the shorthand for the same two overrides. A cache warmed at build under var/cache is not the cache a relocated directory reads, so warm to the path the container will actually use.

Leave the tools behind

The final stage should hold no Composer, no git, no Xdebug, no dev dependencies, no tests/, and no .env.local with real secrets. Each is a tool for whoever gets a shell. Clearing setuid and setgid bits (find / -perm /6000 -type f -exec chmod a-s {} +) removes the binaries that let an unprivileged process become root.

Size is attack surface

Fewer packages mean fewer advisories to triage, and a faster pull on every deploy.

Example

Example · php
<?php
// src/Kernel.php

declare(strict_types=1);

namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    /**
     * Longhand. Moves the writable directories off the read-only root
     * filesystem and onto a path that is mounted writable at run time.
     *
     * The shorthand for both methods is two environment variables,
     * APP_CACHE_DIR and APP_LOG_DIR, each holding a full path. Symfony reads
     * them with no code in this file. Override the methods only when the path
     * has to be computed.
     */
    public function getCacheDir(): string
    {
        return '/mnt/writable/cache/'.$this->environment;
    }

    public function getLogDir(): string
    {
        return '/mnt/writable/log';
    }
}

// With these overrides in place, the build must warm the cache to the same
// path:
//
//   RUN APP_ENV=prod php bin/console cache:warmup
//
// writes to /mnt/writable/cache/prod during the build. If that path is an
// empty writable mount at run time, the warmed cache is not there and the
// first request after each start pays to rebuild it.

When to use it

  • Passing an image scan that fails any container running as root.
  • Making an application survive a read-only root filesystem without losing logging.
  • Removing Composer and git from a production image so a compromised process cannot install code.
  • Cutting image size so a rolling deploy pulls in seconds rather than minutes.

More examples

The hardened final stage

The ordering is the point: copy as root, fix ownership, then drop to the unprivileged user on the last line. Switching users earlier makes every later COPY and RUN fail, or quietly produce files the runtime user cannot read. Group 0 with g=u on var/ is what lets platforms that assign a random UID at start-up still write there.

Example · dockerfile
# Dockerfile (final stage)
FROM php:8.5-fpm-bookworm AS runtime

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions intl opcache pdo_pgsql zip \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
 && rm -rf /var/lib/apt/lists/* /usr/local/bin/install-php-extensions

ENV APP_ENV=prod APP_DEBUG=0
WORKDIR /app

# Application files owned by root, readable by www-data, writable by nobody.
COPY --from=builder --chown=root:root /app /app

# Only var/ is writable, and by group, so an arbitrary UID can still use it.
RUN set -eux; \
    mkdir -p var/cache var/log; \
    chown -R www-data:0 var; \
    chmod -R g=u var; \
    find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true

# Composer and git never reached this stage, so nothing here can fetch or
# install code at run time.
USER www-data

CMD ["php-fpm"]

Restricting the container at run time

Run this against a new image before it reaches production: most images fail immediately, and the error names the path that wanted to be writable. cap_drop ALL removes Linux capabilities the process does not need, and no-new-privileges blocks the setuid escalation route that the chmod a-s in the build already pruned.

Example · yaml
# compose.prod.yaml - the restrictions a production scheduler would apply
services:
  php:
    image: myapp:1.4.0
    user: "www-data"
    read_only: true
    tmpfs:
      - /app/var/cache
      - /app/var/log
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    environment:
      APP_ENV: prod

# The same checks with plain docker, useful before pushing an image:
#
#   docker run --rm --read-only \
#     --tmpfs /app/var/cache --tmpfs /app/var/log --tmpfs /tmp \
#     --cap-drop ALL --security-opt no-new-privileges \
#     -u www-data myapp:1.4.0

Relocating the writable directories with variables

Use the variables rather than the kernel methods when the path differs per deployment: one platform hands you a scratch volume, another a host path, and the same image works on both with no code change. Set them during the build as well if you warm the cache, or the warmed files land somewhere the running container never reads.

Example · bash
# .env.docker
APP_ENV=prod
APP_DEBUG=0

# Shorthand for overriding Kernel::getCacheDir() and Kernel::getLogDir().
# Each takes the full path of the directory.
APP_CACHE_DIR=/mnt/writable/cache
APP_LOG_DIR=/mnt/writable/log

# Paired with a writable mount at that path, the whole of /app - var/
# included - can stay read-only:
#
#   docker run --rm --read-only --tmpfs /mnt/writable \
#     --env-file .env.docker -u www-data myapp:1.4.0

Discussion

  • Be the first to comment on this lesson.