Skip to Content
Docker Advanced: Builds and Containers That Hold Up in Production
Advanced
0 likes
Courses
Docker Advanced: Builds and Containers That Hold Up in Production

Non-Root Users and Dropping Capabilities

See who a container runs as, drop every capability, add a USER line and serve port 80 without root. After this, you can ship images that give an attacker as little as possible.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If every image you build already ends with a USER line and you run with --cap-drop ALL, skip to "Port 80 without root" and "Common mistakes".

Most containers run as root, and most people don't notice because nothing breaks. That is the problem. Root inside a container is not the same as root on the host, but it is still the most powerful user the kernel lets that process be, and container escapes almost always start from root. Running as a normal user and dropping the kernel permissions you don't need costs a few lines. Not doing it costs nothing until the day someone finds a hole in your app.

Everything below was run on Docker Desktop 4.85.0 with Engine 29.6.2, using alpine:3.24 and python:3.14-alpine.

Who you are by default

docker run --rm alpine:3.24 id
docker run --rm python:3.14-alpine id
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)
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)

Both official images run as root unless you say otherwise. This is not a bug in those images. A base image cannot know what user your app should run as. That decision is yours.

Capabilities: root split into pieces

Linux splits root's power into capabilities: change file owners (CHOWN), bind low ports (NET_BIND_SERVICE), send raw packets (NET_RAW), kill other users' processes (KILL) and so on. Docker gives containers a reduced default set, but it still includes some you probably never use. You can see the set as a bit mask in /proc:

docker run --rm alpine:3.24 grep CapEff /proc/self/status
docker run --rm --cap-drop ALL alpine:3.24 grep CapEff /proc/self/status
CapEff:	00000000a80425fb
CapEff:	0000000000000000

The first line is Docker's default set. With --cap-drop ALL it is zero. The process is still called root, but root without capabilities cannot do much:

docker run --rm alpine:3.24 chown nobody /tmp; "exit code $LASTEXITCODE"
docker run --rm --cap-drop ALL alpine:3.24 chown nobody /tmp; "exit code $LASTEXITCODE"
exit code 0
chown: /tmp: Operation not permitted
exit code 1

Same command, same user name. The second one fails because CHOWN is gone. Start from --cap-drop ALL and add back only what the program proves it needs, with --cap-add. Most web apps need none.

A USER line in the Dockerfile

Dropping capabilities helps, but a real user is better, because then the process also cannot write to files owned by root. Create the user in the image and switch to it:

FROM python:3.14-alpine
RUN addgroup -S app && adduser -S -G app -H -s /sbin/nologin app
WORKDIR /app
COPY --chown=app:app server.py .
USER app
CMD ["python", "server.py"]
import http.server, os
port = int(os.environ.get("PORT", "8080"))
print(f"uid {os.getuid()} listening on {port}", flush=True)
http.server.HTTPServer(("", port), http.server.SimpleHTTPRequestHandler).serve_forever()
Set-Location user-demo
docker build -q -t user-app . | Out-Null
docker run --rm user-app id
docker run --rm user-app sh -c "touch /etc/x; apk add curl"
uid=100(app) gid=101(app) groups=101(app),101(app)
touch: /etc/x: Permission denied
ERROR: Unable to open log: Permission denied

The process is now app, with no shell login and no home folder. It cannot write to /etc, and apk add stops at once with "Unable to open log: Permission denied", because the package manager's files belong to root. That second line is the one that matters in an incident: an attacker who gets in cannot simply add what they need.

--chown=app:app on COPY matters too. Without it, the copied files belong to root. That is often fine for code you only read, but the app cannot write logs or uploads next to it. Give the user ownership only of the folders it must write to.

Port 80 without root

The usual excuse for root is "the app must listen on port 80". Inside a container that is not true any more. Docker sets the lowest unprivileged port to 0 inside the container's network namespace, so a normal user can bind port 80 without NET_BIND_SERVICE. I ran the image above as app, on port 80, with every capability dropped:

docker run -d --name user-web -e PORT=80 --cap-drop ALL --security-opt no-new-privileges user-app | Out-Null
Start-Sleep -Seconds 2
docker logs user-web
docker exec user-web wget -qO- http://127.0.0.1:80/ | Select-String -SimpleMatch "server.py"
uid 100 listening on 80

<li><a href="server.py">server.py</a></li>

It works. The last line is the directory listing served by the app, with server.py in it. If your runtime is older or a platform sets that kernel value back, you would see "Permission denied" on the bind. Then publish a high port instead, such as -p 80:8080, rather than going back to root.

--security-opt no-new-privileges stops a process from gaining more rights through programs marked setuid, like su or sudo. It costs nothing for a normal app, so add it.

⚠ Warning: Do not "fix" a permission error by deleting the USER line. Find what the app is trying to write, and give the app user that one folder, or use a volume or tmpfs mount for it.

--user at run time

You can also set the user when you start a container, with a number that does not even exist in the image:

docker run --rm --user 10001:10001 alpine:3.24 id
uid=10001 gid=10001 groups=10001

That is handy for images you don't build yourself. But numbers that are not in /etc/passwd have no name and no home folder, and some programs complain about that. For your own images, put the USER line in the Dockerfile so nobody has to remember the flag.

In Compose

The same settings in a Compose file look like this:

services:
  app:
    image: user-app
    user: "app"
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]

Common mistakes

  • USER app before the RUN lines that install packages. Installing needs root, so switch user at the end.
  • Running as non-root but leaving the whole app folder writable by that user. If the app never writes its own code, don't let it.
  • Adding --privileged to make an error go away. That gives the container every capability and access to host devices. It is the opposite of this lesson.
  • Thinking rootless mode on the host and a USER line are the same thing. Rootless Docker changes who runs the daemon. The USER line changes who runs your app. They protect different things.

💡 Tip: Run docker run --rm --entrypoint id YOUR_IMAGE on every image before it ships. If it says uid=0(root), ask why.

If your app had a bug that let someone run one shell command inside the container, what could that command do as root that it could not do as app?

About

See who a container runs as, drop every capability, add a USER line and serve port 80 without root. After this, you can ship images that give an attacker as little as possible.

Duration
10 min
Last update
08/01/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.