Mert Özen Further With Every Line
DevOps Docker — #13

Container Security: Non-root, Read-only Filesystems, Capabilities, Minimal Images and Scanning

Mert Özen Nov 14, 2026 13 min 13 views
Container Security: Non-root, Read-only Filesystems, Capabilities, Minimal Images and Scanning

Running a container securely means narrowing its default privileges down to what the application really needs: a non-root user, a read-only file system with tmpfs, --cap-drop ALL, no-new-privileges, and memory and process limits. With real output we run Bookstore's API and web containers with these flags, pass the password as a file instead of an environment variable, scan the image with Trivy and see why docker.sock means root on the host.

Version note: The examples in this post were run with Docker Engine 29.9.3, .NET 10 (aspnet:10.0-noble-chiseled-extra, ASP.NET Core Runtime 10.0.15), nginxinc/nginx-unprivileged:1.30-alpine, SQL Server 2025 (RTM-CU11) and Trivy 0.76.2.

Guess First

Bookstore.Api runs from a chiseled image with a non-root user; the database password is passed as in post 12, with -e ConnectionStrings__Default=.... Can a teammate who has no permission to run the container but can use the docker command on the server see that password? Second question: if you start the API and web containers with all Linux capabilities dropped (--cap-drop ALL) and a read-only file system (--read-only), which one fails to start?

What Does a Container Get by Default?

A container is a process, and by default Docker starts it with a reasonable but broad set of privileges. According to Docker's documentation, root in the container gets 14 capabilities: changing file owners (CHOWN), bypassing file permissions (DAC_OVERRIDE), switching users (SETUID, SETGID), opening raw sockets (NET_RAW), listening on ports below 1024 (NET_BIND_SERVICE) and a few more. The root file system is writable, and there is no default limit on memory, CPU or the number of processes. All of this can be seen with alpine:

docker run --rm alpine:3 sh -c 'grep CapEff /proc/self/status; chown 1000 /etc/hostname && echo "chown: ok"'
docker run --rm --cap-drop ALL alpine:3 sh -c 'grep CapEff /proc/self/status; chown 1000 /etc/hostname || echo "chown: failed"'
CapEff:	00000000a80425fb
chown: ok
CapEff:	0000000000000000
chown: /etc/hostname: Operation not permitted
chown: failed

CapEff is the bit mask of the process's effective capabilities. By default it is a80425fb, that is, 14 bits set; with --cap-drop ALL it is zero. The same root user can no longer change a file's owner. The topic of this post is narrowing these defaults down to what the application really needs. The attack scenario is always the same: a flaw in the application (a deserialization bug, a command injection) lets an attacker run code in the container; every step after that is limited by the privileges the container has. Flaws on the application side belong to the Web Security (OWASP Top 10) series; here we look at the walls around the container.

Non-root: in the Image and at Run Time

The first layer was set up in post 8: the API image of Bookstore, the online bookstore used as the shared example across this site's series, runs as uid 1654 with USER $APP_UID, bookstore-web runs as uid 101 with nginx-unprivileged, and SQL Server as uid 10001. Root in a container is uid 0 on the host when user namespaces are not used; running as non-root is the first condition for an attacker who escapes the container not being root on the host as well.

The USER in the image is a default, not a guarantee. docker run --user 0 or runAsUser: 0 in Kubernetes overrides it; sometimes it is done to get past a "permission denied" quickly, and it stays that way. In Kubernetes, runAsNonRoot: true rejects the pod if the image wants to start as root; all of these fields are in the Kubernetes series post "Kubernetes Security: ServiceAccounts, RBAC, Pod Security Standards, securityContext and NetworkPolicy". Almost every Docker flag below has a counterpart in securityContext.

A Read-only File System and tmpfs

--read-only mounts the container's root file system read-only. An attacker cannot download and run a binary, cannot change the application's files and cannot add a line to a config file. The price is that every place the application needs to write to must be stated explicitly. The first half of the answer to the second question came up in bookstore-web (the ★★★ exercise of post 10):

docker run --name dockerseries-web --read-only -e API_BASE_URL=/api dockerseries-bookstore-web:1.1.0
/docker-entrypoint.sh: Launching /docker-entrypoint.d/40-runtime-config.sh
/docker-entrypoint.d/40-runtime-config.sh: line 8: can't create /usr/share/nginx/html/config.json: Read-only file system

Our startup script wrote config.json into the web root. nginx itself also writes its pid file and temporary files to /tmp. The fix is to make /tmp the only writable place and provide it as tmpfs. The script now writes to /tmp/config.json, nginx serves this single file from there, and the COPY --chown=101:101 ... config.json line in the Dockerfile is no longer needed:

envsubst '${API_BASE_URL} ${APP_ENVIRONMENT}' \
    < /etc/bookstore/config.template.json \
    > /tmp/config.json
    # Written to /tmp at startup (40-runtime-config.sh), not into the read-only web root
    location = /config.json {
        root /tmp;
        add_header Cache-Control "no-store";
    }

The image was built with the 1.2.0 tag (the TAG default in docker-bake.hcl is now 1.2.0 too); started with --read-only --tmpfs /tmp:rw,size=8m, /tmp holds config.json, nginx.pid and nginx's *_temp folders, and nothing else is writable. Bookstore.Api started with --read-only without any extra setting; we still give it a /tmp too. The .NET runtime opens the diagnostic socket that tools such as dotnet-counters and dotnet-dump connect to in /tmp; when we looked at /tmp in the API container with tmpfs, the dotnet-diagnostic-1-...-socket file was there. If /tmp is not writable, the application runs, but the diagnostic tools we will use in the next post cannot connect to it.

Dropping Capabilities and no-new-privileges

None of Bookstore's containers needs any of the default capabilities: the API and the web listen on ports above 1024, change no file owners and switch no users. We started both with all capabilities dropped, with no-new-privileges and with limits:

docker run -d --name dockerseries-api --network dockerseries-net --network-alias bookstore-api \
    --read-only --tmpfs /tmp:rw,size=16m \
    --cap-drop ALL --security-opt no-new-privileges \
    --memory 256m --cpus 1 --pids-limit 100 \
    -v "$PWD/secrets:/run/secrets:ro" \
    dockerseries-bookstore-api:1.2.0
docker run -d --name dockerseries-web --network dockerseries-net -p 127.0.0.1:8081:8080 \
    --read-only --tmpfs /tmp:rw,size=8m \
    --cap-drop ALL --security-opt no-new-privileges \
    --memory 64m --pids-limit 50 \
    -e API_BASE_URL=/api dockerseries-bookstore-web:1.2.0

docker inspect dockerseries-api --format \
    'User={{.Config.User}} ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{.HostConfig.CapDrop}} SecurityOpt={{.HostConfig.SecurityOpt}} PidsLimit={{.HostConfig.PidsLimit}}'
docker ps --format "table {{.Names}}\t{{.Status}}"
curl -s localhost:8081/api/books | jq -r '.[0:2][] | .title'
User=1654 ReadonlyRootfs=true CapDrop=[ALL] SecurityOpt=[no-new-privileges] PidsLimit=100
NAMES              STATUS
dockerseries-web   Up 20 seconds
dockerseries-api   Up 20 seconds (healthy)
dockerseries-db    Up 27 seconds
Çalıkuşu
Dune

The answer to the second question: with tmpfs given, both start, the health check passes and the books come from the database. Starting with --cap-drop ALL and giving back only the capability you really need with --cap-add is the right order; when you do the opposite (removing defaults one by one), nobody knows what remains enabled. no-new-privileges stops the process and its children from gaining new privileges: even if the image contains a binary with the setuid bit (su, sudo, older versions of ping), running it does not grant root. A chiseled image has no such binary anyway; the flag is insurance that stays valid even when the image changes.

We did not apply these flags to SQL Server: the database engine may use extra privileges for file ownership and memory management, and its official documentation does not describe this configuration. Before adding a flag to a container, check the settings that software's vendor supports; for the database, not publishing it on the network (post 12) and running it as non-root are the two main layers.

Resource Limits: Memory, CPU and Process Count

We saw the effect of --memory and --cpus on the .NET GC in post 8; from a security angle they mean something else: without limits, one container can eat the whole server's memory and feed everything next to it to the kernel's OOM killer. --pids-limit is the often forgotten third one; it caps the number of processes and threads in the container. We tried a fork bomb:

docker run --rm --pids-limit 50 alpine:3 sh -c 'for i in $(seq 1 100); do sleep 30 & done; wait' 2>&1 | sort | uniq -c
      1 sh: can't fork: Resource temporarily unavailable

At the 50th process the kernel refused the new fork; the container got stuck inside itself, and the host was not affected. .NET threads count toward this number too, so we gave the API 100; an idle ASP.NET Core app uses a few dozen threads, and the thread pool grows under load. Choose the value with headroom by looking at your own app's PIDS column in docker stats.

Secrets: Files, Not Environment Variables

The answer to the first question is yes. Environment variables are part of the container's config, and docker inspect shows them as they are:

docker inspect dockerseries-api --format '{{range .Config.Env}}{{println .}}{{end}}' | grep ConnectionStrings
ConnectionStrings__Default=Server=bookstore-db;Database=BookstoreDb;User Id=bookstore_app;Password=<password>;TrustServerCertificate=True

(I hid the password; the command prints it in plain text.) Anyone who can run the docker command, that is, everyone in the docker group and every tool with access to the Docker API, reads this value. Environment variables are also passed to child processes and leak into error reports, crash dumps and debug lines that say "log the whole environment". In posts 5 and 6 we saw the build side of this (a secret passed with ARG/ENV showing up in docker history); the run-time side is the other half of the same problem.

A better way is to give the secret as a read-only file. ASP.NET Core's key-per-file configuration provider reads every file in a folder as a setting; the file name is the key, the content is the value, and __ in the name is again the section separator. We added one line to Bookstore.Api:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Secrets as files: /run/secrets/ConnectionStrings__Default becomes ConnectionStrings:Default
builder.Configuration.AddKeyPerFile(directoryPath: "/run/secrets", optional: true);
mkdir -p secrets
printf '%s' "$DB_CONN" > secrets/ConnectionStrings__Default
sudo chown 1654 secrets/ConnectionStrings__Default
chmod 0400 secrets/ConnectionStrings__Default
ls -ln secrets
-r--------    1 1654     0              125 Nov 14 12:38 ConnectionStrings__Default

Only uid 1654 in the container can read the file; other users on the host cannot. We mounted the folder with -v "$PWD/secrets:/run/secrets:ro" in the command above; ConnectionStrings no longer appears in the container's environment variables, and docker inspect shows only a bind mount. We chose the /run/secrets path on purpose: Docker Compose and Swarm secrets are also mounted as files in this folder, so the same code works there. We added the secrets/ folder to .gitignore and .dockerignore; keeping the secret out of the repository and the build context matters as much as creating the file. In production this file is filled by a mechanism fed from a secret store (Azure Key Vault, HashiCorp Vault, a Kubernetes Secret); the Configuration and Secret Management series covers that side.

Minimal Images and Scanning

A package that is not in the image has no vulnerabilities. We scanned the Bookstore.Api image and the full Ubuntu-based aspnet:10.0 image with Trivy. We ran Trivy as a container as well; instead of mounting the Docker socket, we saved the image to a tar file with docker save and handed that over (the reason is in the Pitfalls section):

mkdir -p scan
docker save -o scan/api.tar dockerseries-bookstore-api:1.2.0
docker save --platform linux/amd64 -o scan/aspnet.tar mcr.microsoft.com/dotnet/aspnet:10.0
docker run --rm -v "$PWD/scan:/scan:ro" -v dockerseries-trivy-cache:/root/.cache aquasec/trivy:0.76.2 \
    image --input /scan/api.tar --scanners vuln
INFO	[ubuntu] Detecting vulnerabilities...	os_version="24.04" pkg_num=10
...
/scan/api.tar (ubuntu 24.04)
============================
Total: 7 (UNKNOWN: 0, LOW: 1, MEDIUM: 6, HIGH: 0, CRITICAL: 0)

┌──────────┬────────────────┬──────────┬──────────┬───────────────────┬──────────────────┐
│ Library  │ Vulnerability  │ Severity │  Status  │ Installed Version │  Fixed Version   │
├──────────┼────────────────┼──────────┼──────────┼───────────────────┼──────────────────┤
│ libc6    │ CVE-2026-18374 │ MEDIUM   │ fixed    │ 2.39-0ubuntu8.9   │ 2.39-0ubuntu8.12 │
│          │ CVE-2026-84243 │          │          │                   │                  │
...
│ libicu74 │ CVE-2025-5222  │ LOW      │ affected │ 74.2-1ubuntu3.1   │                  │
└──────────┴────────────────┴──────────┴──────────┴───────────────────┴──────────────────┘

(I shortened the table and removed the title column.) The chiseled-extra image has 10 operating system packages that Trivy sees and 7 findings; in the same scan the full Ubuntu image gave 97 packages and 36 findings (Total: 36 (UNKNOWN: 0, LOW: 5, MEDIUM: 31, HIGH: 0, CRITICAL: 0)). There are no findings for the application's Bookstore.Api.deps.json or for the .NET runtime itself.

The first question when reading the list is "is there a fix?". The Status column answers it: fixed means Ubuntu has published the fixed package but our image still carries the old version (8.9). In a chiseled image you cannot run apt-get upgrade, and you should not: the fix arrives when Microsoft rebuilds the base image, and your job is to rebuild on the new base image. affected is a vulnerability with no fix yet; what you can do is assess whether it really affects your application. A CVE being in the image does not mean an attacker can reach it: a flaw in ICU may not be a practical risk if the application never calls that function with outside data. Making that decision and recording it (.trivyignore in Trivy) is the team's job, not silently muting false positives.

In CI, scanning is used as a gate: with --exit-code 1, Trivy returns 1 if any finding passes the filter, and the pipeline stops. For our image, --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 returned 0; the same command including MEDIUM returned 1. Putting this gate into the pipeline, producing an SBOM and signing the image are the topic of the DevSecOps series. The Docker Scout CLI that comes with Docker Desktop (docker scout cves) does the same job; in this post we used Trivy because it is open source and works the same everywhere.

On the base image side, the options grew over the last year. We discussed chiseled and distroless images in post 8. In December 2025 Docker made Docker Hardened Images (a catalog of minimal, continuously patched base images) free for everyone under the Apache 2.0 license; the images are pulled from the dhi.io registry with a free Docker account, and an extra SLA and long-term support are in the paid tiers. We did not try Hardened Images in this post because it requires an account.

Whichever base image you choose, keeping it current is a separate job. The digest pin from post 4 makes the build reproducible but freezes the image with that day's vulnerabilities; you need a bot that keeps the pin current. Renovate's docker:pinDigests preset adds a digest to the tag in FROM lines and opens a pull request when a new digest comes out:

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra@sha256:cae5b962bfb786606def03d250c6a645cc98839095c89e806e4bd8978a68115b AS final
{
  "extends": ["config:recommended", "docker:pinDigests"]
}

GitHub's Dependabot also opens pull requests that update base image tags in Dockerfiles with package-ecosystem: "docker". We did not run either config on GitHub; we wrote them from their documentation only.

In Production

The Docker daemon runs as root, and anyone who can control it is root on the host. Docker's security documentation says to open the daemon only to trusted users. There are two ways to reduce this risk. We set up rootless mode in post 2: the daemon itself runs as a non-root user. You can also leave the daemon as root and map containers to a separate uid range: with "userns-remap": "default" in daemon.json, Docker creates a user called dockremap, and uid 0 in the container is mapped to a non-root uid from the range in /etc/subuid. According to the documentation it has costs: --pid=host and --network=host cannot be used, ownership must be arranged in advance for bind mounts, existing images and containers become invisible once it is enabled, and the containerd image store is not used in this mode. We did not run userns-remap in this post.

Turn the flags into a team standard. The --read-only, --cap-drop ALL, no-new-privileges and limits we typed into every docker run here are one line each in a Docker Compose file and a Kubernetes manifest; put them in the default template, and every new service starts with them.

Scan continuously, not once. An image that is clean today turns red tomorrow with a new CVE. Scan the images in your registry periodically and take base image updates as pull requests.

Common Pitfalls

Mounting /var/run/docker.sock into a container. CI runners, monitoring agents and some scanning tools ask for it. A container with access to the socket can tell Docker to start a new container with the host's root directory mounted:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:29.9.3-cli \
    docker run --rm -v /:/host alpine:3 sh -c 'id; head -1 /host/etc/shadow'
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
root:*::0:::::

A two-line command read /etc/shadow, where the host's password hashes are kept. Mounting the Docker socket is the same as giving that container root on the host. That is why we ran Trivy with docker save output instead of the socket.

"Fixing" the problem with --privileged. When a container cannot do something, adding --privileged makes the error go away, because according to Docker's documentation this flag grants all capabilities and all of the host's devices and turns off the seccomp and AppArmor protections. The right way is to find out which capability is needed and grant only that one with --cap-add.

Putting a secret into the image. COPY secrets/ ... or a file created during the build hands the secret to everyone who pulls the image and stays in the image's whole layer history. For a secret needed during the build, use RUN --mount=type=secret from post 6; for one needed at run time, a file mount.

Trying to close every scan finding by changing the image. Adding a package manager to a chiseled image to patch it throws away everything the minimal image gained. Wait for the new version of the base image or move to a more current one; if there is a vulnerability that cannot wait, record it as a conscious decision.

A Small Experiment

★☆☆ Start the API container with --cap-drop ALL and --read-only but without --tmpfs /tmp. Does the app start? How many does the PIDS column in docker stats show when idle and while you send requests to /books in a row?

★★☆ Start the SQL Server container with --cap-drop ALL --security-opt no-new-privileges. Does it start? If it does not, try to find from the error in the log which capability you need to give back, and compare the result with the vendor's documentation.

★★★ Scan dockerseries-bookstore-web:1.2.0 with aquasec/trivy. Which layer are the findings in (Alpine packages or nginx)? Produce an SBOM with --format cyclonedx and check whether the Angular packages are in it; why are they not?

The containers now run with the least privilege. That has a price: there is no shell in the chiseled image, the file system is read-only and the capabilities are dropped; when something goes wrong, getting inside to look is not as easy as it used to be. In the next post we move on to container debugging: a systematic diagnosis flow with logs, inspect, stats and events, looking into a shell-less container with a helper container and docker debug, and a Bookstore.Api that dies with 137.