User, Capabilities, Read-Only Filesystem and Seccomp
A container that runs as root, with every Linux capability and a writable filesystem, is technically speaking a privileged process behind a thin namespace curtain. Docker offers several complementary security layers: a non-root USER, minimal capabilities, a read-only filesystem, and seccomp profiles. This article shows how these layers work and how to implement them in real Dockerfiles and Compose configurations.
Table of Contents
- 1. Understanding the container threat model
- 2. Non-root USER: the single most important measure
- 3. Linux capabilities: minimal privileges for containers
- 4. Read-only filesystem: protection against runtime modification
- 5. Seccomp profiles: syscall filtering for containers
- 6. AppArmor and LSM: mandatory access control
- 7. Security configuration in Docker Compose
- 8. Security audit: Docker Bench and Trivy
- 9. Security layers compared
- 10. Summary
- 11. FAQ
1. Understanding the container threat model
Misunderstandings about container security often start with a flawed mental model: containers are not virtual machines. They share the Linux kernel with the host and with every other container on the same host. A vulnerability in the kernel namespace system, a flawed syscall implementation, or a kernel exploit can let an attacking process inside a container escalate into the host context. The question is not whether containers provide isolation, they do. The question is how strong that isolation is and against which attack vectors it actually holds.
The realistic attack scenario for web containers looks like this: an application has a remote code execution vulnerability (RCE). An attacker manages to run arbitrary code inside the container. At that point, the container security configuration decides what that code can do: can it write to the filesystem? Can it open network connections? Can it execute privileged syscalls? Can it reach other containers on the same host? Every security layer, non-root, read-only filesystem, capability drop, seccomp, answers one of these questions with "no" and thereby limits the blast radius of a successful attack.
Defense in depth is the principle behind the different container security layers: no single measure is absolutely secure, but several layers stacked together raise the effort for an attacker exponentially. A non-root container with a read-only filesystem, minimized capabilities, and a seccomp profile is significantly harder to abuse than a root container with all the defaults. This article covers each layer individually, explains its protection mechanism, and shows how to implement it in production Dockerfiles and Compose configurations.
2. Non-root USER: the single most important measure
By default, Docker containers run as root (UID 0). That means a process inside the container has the same privileges as root on the host, unless other restrictions apply. For many attack paths, escapes via namespace vulnerabilities, mountpoint exploits, having root inside the container is a prerequisite. The simplest and most effective container security measure is therefore running the container as an unprivileged user: the USER directive in the Dockerfile sets the user for all subsequent RUN, CMD, and ENTRYPOINT instructions.
Putting this into practice takes care: files and directories the application needs to access must be owned by the application user. Ports below 1024 can only be bound by root, so containers should listen on ports above 1024 (typically 8080 instead of 80) while an external load balancer or reverse proxy handles the port forwarding. The USER concept is complemented by user namespace remapping at the daemon level: with user namespace remapping enabled, root inside the container is mapped to an unprivileged host UID, adding another protective ring against container escape exploits.
# Dockerfile: Non-root user setup for PHP-FPM application
FROM php:8.4-fpm-alpine
# Create application user with specific UID/GID
# Use fixed IDs for predictable ownership in volume mounts
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /var/www/html
# Install dependencies as root (they modify system dirs)
RUN --mount=type=cache,id=apk-cache,target=/var/cache/apk \
apk add --no-cache libzip libpng
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Copy application, owned by appuser from the start
COPY --chown=appuser:appgroup composer.json composer.lock ./
# Install dependencies as root, but with COMPOSER_CACHE_DIR owned by appuser
RUN --mount=type=cache,id=composer,uid=1001,gid=1001,target=/composer-cache \
COMPOSER_CACHE_DIR=/composer-cache composer install \
--no-dev --no-interaction --prefer-dist --no-scripts --optimize-autoloader
COPY --chown=appuser:appgroup . .
# PHP-FPM config: listen as appuser
RUN sed -i 's/www-data/appuser/g' /usr/local/etc/php-fpm.d/www.conf
# Switch to non-root user: all subsequent operations run as appuser
USER appuser
# Health check runs as appuser
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD php-fpm -t || exit 1
EXPOSE 9000
CMD ["php-fpm"]
3. Linux capabilities: minimal privileges for containers
Linux splits root privileges into granular units: Linux capabilities. By default, Docker grants containers a defined subset of these capabilities, including CHOWN, DAC_OVERRIDE, SETUID, NET_BIND_SERVICE, and others. Each of these capabilities is a potential foothold for privilege escalation. The principle of least privilege recommends dropping all capabilities (--cap-drop=ALL) and adding back only the ones explicitly needed (--cap-add=NET_BIND_SERVICE). Most web applications need no capabilities at all beyond NET_BIND_SERVICE (and only if binding to a port below 1024).
In practice, the hardest question is: which capabilities does my application actually need? This can be worked out step by step with docker run --cap-drop=ALL --cap-add=... , adding capabilities one at a time until the application works. A faster route is docker run --security-opt=no-new-privileges:true: this flag prevents processes inside the container from acquiring new privileges via SUID binaries or syscalls, even if capabilities are still present. no-new-privileges is a sensible addition to --cap-drop and guards against a class of privilege escalation attacks that cap-drop alone does not cover.
4. Read-only filesystem: protection against runtime modification
The read-only filesystem (--read-only) prevents processes inside the container from modifying the container image's filesystem. This is a strong defense against a common attack technique: after an initial RCE vulnerability, attackers write malware, web shells, or backdoors to the filesystem, which then execute on the next request or container start. With --read-only, no write access is possible, so these persistence mechanisms simply do not work.
The practical problem: many applications need to write to temporary directories, PHP session files, log files, temporary upload files. The solution is selective tmpfs mounts on exactly the directories that need to be writable. A tmpfs mount is a RAM-backed filesystem that disappears when the container stops, so it offers no persistence foothold for malware. With --tmpfs /tmp:noexec,nosuid,nodev,size=256m, /tmp becomes writable, but executables placed there cannot be run (noexec). Typical tmpfs mounts for PHP applications: /tmp, /var/lib/php/sessions, /run, and application-specific cache directories.
# docker-compose.yml: Full security hardening for a PHP web container
services:
web:
image: registry.example.com/app:${APP_VERSION}
user: "1001:1001"
read_only: true
# Grant write access only to specific directories via tmpfs (in-memory, no exec)
tmpfs:
- /tmp:mode=1777,noexec,nosuid,nodev,size=128m
- /var/lib/php/sessions:mode=0700,noexec,nosuid,size=64m
- /var/run:noexec,nosuid,size=16m
# Drop ALL capabilities, add back only what is strictly needed
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only needed if binding port < 1024 (prefer 8080!)
security_opt:
- no-new-privileges:true
- seccomp:./seccomp/app-profile.json # Custom seccomp profile
- apparmor:docker-app-profile # AppArmor profile (if available)
# Prevent container from running as privileged
privileged: false
# Limit resources to reduce DoS blast radius
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
memory: 128M
# Health check verifies actual application health
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
5. Seccomp profiles: syscall filtering for containers
Seccomp (Secure Computing Mode) is a Linux kernel feature that restricts a process's syscalls to an allowed whitelist. By default, Docker applies a seccomp profile that already blocks around 44 dangerous syscalls, including ptrace, perf_event_open, and several namespace syscalls that can be used for container escape exploits. For maximum hardening, an application-specific seccomp profile can be created that only allows the syscalls actually needed.
Building a minimal seccomp profile for an application is laborious but doable: using strace -f -e trace=all ... 2>&1 | grep syscall or seccomp's audit mode, you can observe which syscalls an application actually uses under real conditions. That list then becomes the seccomp profile's whitelist. Docker's default profile is a good starting point; the difference lies in specifically blocking syscalls a PHP application never needs (mount, swapon, reboot, all namespace syscalls). The seccomp profile format is JSON and is applied via --security-opt seccomp=profile.json.
6. AppArmor and LSM: mandatory access control
AppArmor is a Linux Security Module (LSM) that implements mandatory access control (MAC) on a path basis. An AppArmor profile for a container defines which file paths the container may read from and write to, which network operations are allowed, and which capabilities can be used. On Ubuntu and Debian-based hosts, Docker applies the docker-default profile by default, which provides solid baseline protection.
For critical containers, an application-specific AppArmor profile makes sense. It is installed on the host (apparmor_parser -r -W /etc/apparmor.d/docker-app-profile) and assigned to the container via --security-opt apparmor=docker-app-profile. The combination of non-root user, capability drop, read-only filesystem, seccomp, and AppArmor forms a defense-in-depth architecture in which each layer covers a different attack vector, so the failure of a single layer does not lead to full compromise.
7. Security configuration in Docker Compose
Docker Compose translates security configuration directly into Docker run parameters. The key fields: user for the container user, read_only: true for the read-only filesystem, cap_drop and cap_add for capabilities, security_opt for seccomp profiles and AppArmor, privileged: false (set it explicitly to document the default) and tmpfs for writable in-memory directories. These settings apply per service and can be defined in a base Compose file as a template that other services pull in via extends.
A common mistake: privileged: true gets set for debugging purposes and is then forgotten. In production, privileged: true effectively disables every container security layer, the container gets full access to the host. An automated lint step in the CI pipeline that checks Compose files for privileged: true prevents this kind of oversight. docker compose config prints the resolved configuration and shows which security parameters are actually active, including defaults. It is a valuable debugging tool for container security audits.
8. Security audit: Docker Bench and Trivy
Docker Bench for Security is an open-source script that checks Docker installations and container configurations against the CIS Docker Benchmark. It checks host configuration, Docker daemon settings, image security, and container runtime configuration. Running Docker Bench automatically in the CI pipeline after the build identifies configuration issues before an image reaches production. The output is structured: PASS, WARN, and INFO entries with references to the CIS Benchmark and concrete recommendations.
Trivy complements Docker Bench with vulnerability scanning: it checks the image against known CVEs in base image packages, application dependencies (composer.lock, package-lock.json), and configuration files. Trivy can be integrated directly into CI pipelines and fails the build on critical CVEs before the image is deployed. The combination of static image scanning (Trivy) and configuration auditing (Docker Bench) covers the most important container security aspects automatically, turning security into a step in the normal deployment pipeline rather than a manual review.
# ci-security-check.sh: Automated container security validation in CI pipeline
set -euo pipefail
IMAGE="${1:?Image name required}"
FAIL_ON_SEVERITY="${2:-CRITICAL}"
echo "=== Trivy: Vulnerability scan ==="
trivy image \
--exit-code 1 \
--severity "${FAIL_ON_SEVERITY}" \
--no-progress \
--format table \
"${IMAGE}"
echo "=== Trivy: Dockerfile misconfiguration check ==="
trivy config \
--exit-code 1 \
--severity HIGH,CRITICAL \
--no-progress \
Dockerfile
echo "=== Non-root user check ==="
USER=$(docker inspect "${IMAGE}" --format '{{.Config.User}}')
if [ -z "$USER" ] || [ "$USER" = "root" ] || [ "$USER" = "0" ]; then
echo "[FAIL] Container runs as root, set USER in Dockerfile"
exit 1
fi
echo "[PASS] Container user: ${USER}"
echo "=== Docker Bench (subset): image checks ==="
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
docker/docker-bench-security \
-c container_images 2>/dev/null \
| grep -E "^\[WARN\]|^\[PASS\]|^\[FAIL\]"
echo "=== All security checks passed ==="
9. Security layers compared
The different container security measures address different attack vectors and carry different implementation effort. No single measure is sufficient on its own.
| Security layer | Attack vector | Effort | Impact |
|---|---|---|---|
| Non-root USER | Privilege escalation, kernel exploits | Low | High |
| Read-only filesystem | Malware persistence, web shells | Low (+ tmpfs) | High |
| Capability drop | Privilege escalation via syscalls | Medium (testing required) | High |
| no-new-privileges | SUID exploits, privilege escalation | Minimal | Medium |
| Seccomp profile | Kernel exploits via syscalls | High (build a profile) | Very high |
For most production containers, the practical recommended stack is: non-root USER (always), read-only filesystem with tmpfs for write directories (almost always), --cap-drop=ALL with minimal --cap-add (for every new service), no-new-privileges (always, at no cost), Docker's default seccomp profile (active automatically, verify manually). Custom seccomp profiles pay off for especially critical services. The security stack is not a one-time effort, it has to be part of the normal Dockerfile review and the CI pipeline.
Mironsoft
Container security, hardening audits, and security pipeline integration
Ready to systematically harden your container infrastructure?
We analyze your container configurations against the CIS Benchmark, implement non-root, read-only filesystem, capability drop, and seccomp profiles, and integrate Trivy and Docker Bench into your CI/CD pipeline.
Security audit
Reviewing Dockerfile and Compose configurations against the CIS Docker Benchmark
Hardening implementation
Non-root, read-only filesystem, capability drop, and seccomp for production containers
CI security pipeline
Integrating Trivy scanning and Docker Bench checks automatically into every build pipeline
10. Summary
Container security is not a single measure but a defense-in-depth stack. A non-root USER stops an attacking process from having root privileges. A read-only filesystem with tmpfs for write directories prevents malware persistence. Capability drop with --cap-drop=ALL and minimal --cap-add restricts privileged operations. no-new-privileges blocks SUID-based privilege escalation. Docker's default seccomp profile blocks dangerous kernel syscalls. Together, these layers drastically reduce the blast radius of a successful attack.
The most important practical takeaway: most of these measures carry no performance cost and create no development friction. Non-root, no-new-privileges, and the default seccomp profile are effectively free. Read-only filesystem with tmpfs requires a few lines of configuration. Capability drop requires testing. The total effort for a solidly hardened container setup is one to two days of implementation work, and the security improvement is permanent, paying off on every deployment cycle that follows.
Container Security: The essentials at a glance
Non-root USER
USER 1001:1001 in the Dockerfile. Pass all application files with --chown. Use ports above 1024, no NET_BIND_SERVICE needed.
Read-only filesystem
--read-only plus tmpfs for /tmp and /run with noexec,nosuid. Prevents malware persistence after RCE exploits.
Capabilities
--cap-drop=ALL, then only explicitly needed --cap-add. Always set no-new-privileges:true, it blocks SUID exploits.
Seccomp and audit
Keep Docker's default seccomp active or use a custom profile for critical services. Integrate Trivy plus Docker Bench into the CI pipeline.