◀ Back to blog
Docker

Docker security: best practices

Published on 20 May 2024· 8 min read
#Docker#Sécurité#DevOps

Securing your Docker containers

Container security is a critical topic. Here are the essential practices to protect your containerized applications.

A container is not a virtual machine: all containers on a host share the same Linux kernel. Isolation relies on namespaces, cgroups, capabilities and seccomp profiles. It is effective, but every overly permissive setting (root process, unnecessary capabilities, mounted Docker socket, open port) widens what an attacker can do after compromising an application. The goal is therefore to reduce the attack surface layer by layer: the image, the user, runtime privileges, the network and secrets.

Principle of least privilege

# NEVER run as root
FROM php:8.3-fpm-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .

By default, a process inside a container runs as root. If the application is compromised (command injection, vulnerable dependency), the attacker is root inside the container, can modify the code, install tools, and is in a better position to attempt an escape to the host. With USER, the main process runs as an unprivileged system user, created here with Alpine's -S options.

Two details for PHP-FPM: since the master process is no longer root, it cannot switch users, and the pool's user/group directives are ignored (with just a warning in the logs). It must also listen on a port above 1024, which is the case for the default port 9000. Finally, COPY --chown gives ownership of the files to the application user; if the code does not need to be writable, it is even better to leave it owned by root and only open the cache and log directories for writing.

Least privilege also applies at runtime. By default, Docker grants a set of Linux capabilities that a web application almost never needs. Drop them all, forbid privilege escalation and make the filesystem read-only:

services:
  app:
    image: registry.example.com/myapp:1.4.2
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
      - /app/var
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

The no-new-privileges option prevents a setuid binary from raising the process's privileges. read_only mode blocks writes everywhere except in the declared tmpfs mounts: here /tmp and Symfony's var/ directory (cache and logs). If your application writes anywhere else, it will fail at startup, which is an excellent way to discover what it actually does. Never run a container with --privileged in production: that option disables most of the isolation.

Protecting the Docker socket

Mounting /var/run/docker.sock into a container amounts to giving it full control of the host: it can start a privileged container that mounts the root filesystem. Reserve it for the rare tools that really need it (reverse proxy, monitoring agent), read-only and ideally behind a socket proxy that filters the allowed calls. For the same reason, adding a user to the docker group is equivalent to giving them root. Docker's rootless mode, where the daemon itself runs without privileges, greatly reduces this impact.

Scanning for vulnerabilities

Integrate a vulnerability scanner into your pipeline:

# With Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image myapp:latest

# With Docker Scout
docker scout cves myapp:latest

These tools compare system packages and application dependencies (including composer.lock) against databases of known CVEs. For a scan to be useful, it must block the pipeline when it finds a serious flaw. With Trivy, the exit code is configurable:

trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
  registry.example.com/myapp:1.4.2

--ignore-unfixed skips vulnerabilities for which no fix exists yet, so the pipeline is not blocked by alerts that cannot be acted on. Also scan images already in production periodically: new CVEs are published every day for packages that were clean at build time. And rebuild your images regularly to pick up fixes from the base image.

Network and isolation

  • Create dedicated networks to isolate services
  • Never expose database ports
  • Use Docker secrets rather than environment variables for sensitive data
  • Enable read-only mode for stateless containers

A network declared with internal: true has no access to the outside: the database can talk to the application on it, but can neither be reached from the Internet nor open outbound connections. Only the reverse proxy is published on the host:

services:
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "443:443"
    networks: [frontend]
  app:
    image: registry.example.com/myapp:1.4.2
    networks: [frontend, backend]
  db:
    image: mysql:8.4
    networks: [backend]

networks:
  frontend:
  backend:
    internal: true

Remember that ports published by Docker are opened through iptables rules that are often evaluated before UFW's: a published port is reachable even if the firewall seems to block it. If a service must only be reachable from the host, publish it on 127.0.0.1. How networks work is covered in the Docker networking guide.

Secrets management

services:
  app:
    secrets:
      - db_password
      - api_key

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    external: true

Environment variables leak easily: they show up in docker inspect, are inherited by every child process and sometimes end up in error reports. A secret is mounted as a file at /run/secrets/<name>, readable only in the containers that declare it. A file secret is read from the host (the file must obviously not be committed); an external secret must already exist in the cluster (docker secret create), which requires Docker Swarm.

Many official images accept variables suffixed with _FILE (for example MYSQL_PASSWORD_FILE). On the Symfony side, the file and trim environment variable processors read the file directly:

# config/packages/doctrine.yaml
parameters:
    env(DB_PASSWORD_FILE): '/run/secrets/db_password'

doctrine:
    dbal:
        driver: pdo_mysql
        host: db
        dbname: app
        user: app
        password: '%env(trim:file:DB_PASSWORD_FILE)%'

Also watch out for secrets needed during the build (an access token for a private Composer repository, for example). An ARG or ENV remains visible in the image history. Use a BuildKit secret mount instead, which is never written to a layer:

# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=composer_auth,target=/app/auth.json \
    composer install --no-dev --no-scripts --prefer-dist

# Build: docker build --secret id=composer_auth,src=$HOME/.composer/auth.json .

Trusted images

Only use official or verified images. Pin exact versions rather than using latest. Sign your images with Docker Content Trust.

A tag such as php:8.3-fpm-alpine moves: it points to a new image with every update. For full reproducibility, pin the digest (FROM php:8.3-fpm-alpine@sha256:…) and let a tool such as Renovate or Dependabot propose updates. As for signing, Docker Content Trust relies on Notary v1, which Docker is phasing out; to sign your own images, Sigstore Cosign is today the most widely used option:

# Find out an image's digest
docker buildx imagetools inspect php:8.3-fpm-alpine

# Sign then verify an image with Cosign
cosign sign --key cosign.key registry.example.com/myapp:1.4.2
cosign verify --key cosign.pub registry.example.com/myapp:1.4.2

Finally, choose minimal base images (Alpine, slim variants or distroless images): every package that is absent is one less potential vulnerability.

Checklist

  • Minimal, versioned base image, rebuilt regularly
  • Non-root process, capabilities dropped, no-new-privileges
  • Read-only filesystem for stateless containers
  • Blocking vulnerability scan in CI
  • Separate networks, databases not published
  • Secrets as files, never in the image or the repository
  • Docker socket never mounted into an application container

None of these measures is enough on its own, but together they turn the compromise of an application into a contained incident rather than a takeover of the server.