Container Security Fundamentals

Container Security Fundamentals

Bitnesia Sep 12, 2026 12 ID

Containers running without security considerations are essentially ordinary processes on a host that happen to be wrapped in namespaces and cgroups. If the processes inside run as root with all default capabilities still attached, an application vulnerability can lead to consequences far worse than a simple crash. Attackers could modify the file system, read the Docker socket, or in a worst-case scenario, escape the container onto the host. Sysadmins, DevOps Engineers running containers in production, and Developers writing Dockerfiles for their applications all play a role in mitigating these risks. This chapter covers the fundamental practices of container security: why container isolation should not be equated with virtual machine isolation, how to configure user privileges and Linux capabilities to run containers with the least necessary access, how to lock down the container filesystem to read-only, and how to audit Docker host security configurations systematically.

22.1 Container Security Principles

Before diving into technical configurations, it is essential to understand the limits of the container isolation model so that security expectations remain realistic. This section covers the core principles that form the foundation for all container security practices in subsequent subchapters.

22.1.1 Containers Share the Kernel with the Host

Unlike virtual machines, which each have their own kernel, all containers on a single Docker host share the host's Linux kernel. Isolation between containers, as well as between containers and the host, is achieved through a combination of namespaces (restricting what a process can see), cgroups (restricting the resources a process can use), and Linux capabilities (restricting which privileged operations can be performed). Consequently, a vulnerability in the Linux kernel itself can potentially be exploited from inside a container to affect the host, an outcome far harder to achieve in virtual machine-level isolation with its separate hypervisor layer. Therefore, keeping the host kernel updated with the latest security patches is just as crucial as securing the container configuration itself.

22.1.2 The Principles of Least Privilege and Defense in Depth

Two guiding principles for nearly all container security practices in this chapter are least privilege and defense in depth. Least privilege means a container should only be granted the minimum access rights, capabilities, and resources strictly required for its application to run, rather than sticking to permissive default configurations intended for broad compatibility. Defense in depth means security should not rely on a single layer alone; combining a non-root user, dropped capabilities, a read-only filesystem, and routine scanning provides far stronger protection than relying on a single control. If one layer fails or is bypassed, other layers continue to contain the impact.

In practice, applying these two principles usually starts with the lowest-cost, highest-impact measure: ensuring that application processes do not run as root inside the container. The next subchapter details this approach.

22.2 User Privileges in Containers

One of the most common configuration mistakes Sysadmins and DevOps Engineers encounter during security audits is running application processes as root inside containers, even when the application itself never requires such elevated privileges. This section discusses why this poses a risk and how to run containers using a non-root user.

22.2.1 The Problem with Root by Default

If the USER instruction is omitted from a Dockerfile, processes inside the container run as root by default. Although root inside a container is not automatically equivalent to root on the host due to user ID isolation via namespaces, without additional configuration, UID 0 inside the container still maps to UID 0 on the host. This means if an attacker successfully exploits an application vulnerability to execute arbitrary commands inside the container, those commands will execute with full root privileges within that container. This includes the ability to read and write any file in the container filesystem, modify container network settings, or leverage remaining capabilities to attempt an escape beyond isolation boundaries.

22.2.2 Running Containers as Non-Root

The most direct way to avoid this issue is to define a non-root user directly within the Dockerfile using the USER instruction. This ensures that the resulting image runs with limited privileges by default, without relying on operators remembering to append specific flags every time a container is launched.

FROM node:20-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup . .

USER appuser

CMD ["node", "server.js"]

The addgroup and adduser commands above use Alpine Linux syntax; other base image distributions like Debian or Ubuntu use the equivalent groupadd and useradd commands. Note the use of --chown with COPY so that application files are immediately owned by the non-root user, rather than remaining owned by root, which would prevent appuser from writing to its own application directory.

If the image being used cannot have its Dockerfile modified, such as with third-party base images, a non-root user can also be enforced directly when running the container using the --user flag with docker run, provided that the specified user or UID already exists in the image.

docker run -d --user 1000:1000 payment-service:1.4.2

Verify the actual user running the process inside the container using docker exec. Relying solely on assumptions from the Dockerfile is insufficient, especially if the image was modified by another party or if the --user flag was accidentally omitted during deployment.

docker exec payment-service whoami
docker exec payment-service id

22.2.3 User Namespace Remapping

For an additional layer of protection across all containers on a host, Docker Engine supports user namespace remapping via the userns-remap option in daemon.json. This feature maps UID 0 (root) inside the container to an unprivileged UID on the host. Even if a process runs as root inside the container, from the host's perspective it actually runs as a standard UID with no special privileges. This provides a valuable safety net even if the USER instruction was forgotten in the Dockerfile.

{
  "userns-remap": "default"
}
sudo systemctl restart docker

Setting the value to default instructs Docker Engine to automatically create a dedicated user and subordinate UID/GID range for remapping. After restarting the daemon, check the Docker data directory to ensure remapping is active; container data directories normally owned by root will now be owned by the remapped UID.

sudo ls -ld /var/lib/docker/*/

Note that userns-remap has several limitations to review before enabling it in production. For example, it is incompatible with containers running with --privileged, and it can affect containers that share namespaces using flags like --pid=host or --net=host. Furthermore, the Docker daemon itself still runs as full root even when userns-remap is enabled. If the goal is to run both the daemon and all containers completely without root privileges, consider Rootless mode, which requires a different setup outside the scope of this chapter.

22.3 Linux Capabilities

In addition to managing the user running a process, a more granular control layer involves Linux capabilities, which break traditional root privileges down into smaller, individually assignable or revocable units.

22.3.1 Default Capabilities and Their Risks

Linux capabilities divide privileges historically held entirely by the root user into separate units, such as NET_BIND_SERVICE to bind a socket to ports below 1024, or SYS_CHROOT to call chroot(). By default, Docker runs containers with only a small subset of all available Linux capabilities. However, this default list still includes capabilities like CHOWN, DAC_OVERRIDE, SETUID, and SETGID. If exploited through an application vulnerability, these capabilities can still compromise container integrity. Most standard web applications or backend services do not require any of these default capabilities to function normally.

22.3.2 Dropping and Adding Capabilities

The recommended best practice from official Docker documentation is to drop all capabilities first using --cap-drop=ALL, and then explicitly add back only the specific capabilities required by the application using --cap-add.

docker run -d \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  -p 80:80 \
  nginx:1.27

In the example above, nginx needs to bind to port 80 and can still run normally even though all other capabilities have been dropped, because the only required capability, NET_BIND_SERVICE, was explicitly added back. To determine which capabilities an application actually uses, the most practical approach in the field is to start with --cap-drop=ALL and observe any errors that occur during application startup or operation. A permission denied error message related to network or filesystem operations usually points directly to the capability that needs to be added back.

22.3.3 The Risks of the --privileged Flag

The --privileged flag grants all capabilities to the container, enables access to all host devices, and relaxes AppArmor or SELinux restrictions. This gives processes inside the container access nearly equivalent to processes running directly on the host. Docker explicitly warns that this flag should be used with extreme caution. An attacker executing code inside a container running with --privileged has a much easier path to execute a container escape onto the host compared to a container with restricted capabilities.

Requirements that seem to demand --privileged, such as accessing a specific host device, can typically be fulfilled in a much more restricted manner using the --device flag, which exposes only specific devices without granting access to all host devices.

docker run -d --device=/dev/ttyUSB0 iot-gateway:1.0

Before enabling --privileged in production, verify whether the requirement can be satisfied using a targeted combination of --cap-add and --device. Both options offer far narrower, more controlled access than granting full privileges all at once.

22.4 Read-Only Filesystem

Beyond controlling who runs the process and what operations are permitted, the next layer of security involves restricting what can be modified within the container's own filesystem.

22.4.1 Locking the Filesystem with the --read-only Flag

Most applications, particularly stateless backend services, do not need to write to their container filesystem, except perhaps to temporary directories like /tmp. Locking the container filesystem to read-only using the --read-only flag prevents an attacker who gains access to the container from writing new files, modifying existing binaries, or planting persistent backdoors in the container filesystem.

docker run -d --read-only payment-service:1.4.2

If an application requires write access to specific directories, such as temporary cache files or runtime sockets, pair --read-only with a tmpfs mount. This provides a dedicated memory-backed writable directory without making the entire filesystem writable.

docker run -d \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  payment-service:1.4.2

Combining --read-only with --tmpfs, --user, and --cap-drop=ALL within a single docker run command provides multiple protection layers simultaneously, aligning with the principle of defense in depth discussed earlier.

docker run -d \
  --user 1000:1000 \
  --cap-drop=ALL \
  --read-only \
  --tmpfs /tmp \
  payment-service:1.4.2

22.4.2 Verification and Troubleshooting

Ensure the --read-only flag is active and effective using docker inspect, then attempt to write a test file into the container to confirm that write operations are denied.

docker inspect --format='{{.HostConfig.ReadonlyRootfs}}' payment-service
docker exec payment-service sh -c "echo test > /app/test.txt"

The command above should fail with a read-only file system error message if configured correctly. If an application crashes or fails to start after enabling --read-only, the most common cause is that it tried to write to a directory not mounted as a tmpfs, such as a log or framework cache directory. Review application error logs to identify the failed path, then add a tmpfs mount for that path.

22.5 Security Auditing with Docker Bench

Manually applying these practices to every container can be time-consuming and error-prone, especially on hosts running many containers simultaneously. This section explains how to audit Docker Engine and container security configurations systematically.

22.5.1 CIS Docker Benchmark

The CIS Docker Benchmark is a collection of Docker security configuration recommendations published by the Center for Internet Security (CIS). It covers host configuration, Docker daemon setup, images, Dockerfiles, and runtime container settings. This benchmark serves as an industry standard reference used by Sysadmins and DevOps Engineers to assess Docker security posture. Key recommendations align directly with practices covered in this chapter, such as avoiding the --privileged flag without strong justification and running containers as non-root users.

22.5.2 Running Docker Bench for Security

Docker Bench for Security is an open-source audit script from Docker that automates most checks from the CIS Docker Benchmark. It evaluates host, daemon, and running container configurations, reporting results as items that passed, failed, or require manual inspection. The official docker/docker-bench-security image previously hosted on Docker Hub is no longer actively maintained and may contain outdated dependencies. Official documentation recommends cloning the repository and building the image locally before use.

git clone https://github.com/docker/docker-bench-security.git
cd docker-bench-security
docker build --no-cache -t docker-bench-security .

Once the local image is built, execute the audit using the following command:

docker run --rm --net host --pid host --userns host --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /etc:/etc:ro \
  -v /usr/bin/containerd:/usr/bin/containerd:ro \
  -v /usr/bin/runc:/usr/bin/runc:ro \
  -v /usr/lib/systemd:/usr/lib/systemd:ro \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --label docker_bench_security \
  docker-bench-security

The multiple -v options in the command allow the script inside the container to read various host configuration files and directories in read-only mode for inspection, without granting write permissions to the host system. Scan results are output directly to the terminal, labeled with [PASS], [WARN], [INFO], and [NOTE] codes for each benchmark item.

22.5.3 Acting on Audit Results

Not every [WARN] finding from Docker Bench for Security requires immediate remediation without evaluating context. Some recommendations are highly strict and may not apply to every scenario, such as enabling userns-remap despite its compatibility constraints. Sysadmins and DevOps Engineers should review findings within their specific environment context, prioritize remediation for containers exposed to public networks or handling sensitive data, and document justifications if certain recommendations are intentionally skipped. Running Docker Bench for Security periodically, rather than just once during initial setup, helps catch configuration drift over time, such as a new container accidentally launched with --privileged during debugging and forgotten afterward.