CodeGym /Kursy /Docker for Spring /Non-root user and the USER instruction

Non-root user and the USER instruction

Docker for Spring
Poziom 24, Lekcja 0
Dostępny

1. Running as root is a bad baseline

If you have ever launched an application with sudo because “it is faster,” you already have a feel for where this is going. Running a process as root inside a container is roughly the same thing, only with an extra bonus: you do it not just once on your machine, but turn it into a template that other people, other services, and maybe future you will repeat. And future you, as we know, is extremely vindictive.

By default, the first process in a container runs as root (uid=0), and that is considered standard Docker behavior. The docs describe this directly: the default user inside a container is root, and you can change it with the Dockerfile USER instruction or with the -u option in docker run. That is convenient while learning: fewer “why can’t I create the directory” moments and less pain from permissions. But in the final template, that kind of “convenience” turns into an anti-pattern, because root gives the application more rights than it needs for normal operation.

The practical point here is simple and free of ideology. Inside the container, your Spring Boot service should be able to accept HTTP requests, read configuration, and write only to pre-agreed writable directories (for example, an export directory). It does not need the ability to modify system files, create users “just in case,” or write anywhere it wants. The fewer privileges a process has, the smaller its “blast radius” if something goes wrong. And even if you are not deploying the service to production yet, the habit “root is normal” easily survives the training repository and leaks into real projects.

There is one more very pragmatic argument. Root often hides problems that later surface at the worst possible moment. While the process is root, it can almost always “write a file,” “create a directory,” “open something weird.” And when you switch to non-root, or land in an environment where root is blocked by policy, you suddenly discover that your application was “working” only because it had a cheat code. Today we are revoking that cheat code.

2. Build-time and runtime: different users

After the multi-stage build topic, you should already have the feeling that a Dockerfile is not “one container,” but a script with several acts. In the first act, we build the artifact: Gradle, source code, dependencies, the bootJar build. In the second act, we run the application: only the runtime and what the service really needs remain. And here is the key idea: a user is just as much a part of that separation as JDK vs JRE.

The builder stage almost always lives like a construction site. There we download dependencies, run Gradle, unpack the jar into layers, and we want the build to be reproducible and understandable. The runtime stage is the “apartment you will later move into.” It should not have extra tools, and it definitely should not have “I have the keys to the whole house.”

It helps to keep a simple table in mind — not as dogma, but as a compass:

Stage in the Dockerfile Typical actions Can it be root?
builder build the jar, download dependencies, unpack layers usually yes, that is fine
runtime run java -jar ... no, the final template is non-root

And here is another nuance that often trips up beginners: the USER instruction affects not only how the container starts, but also how the following commands in the Dockerfile run. So “I put USER appuser in the middle of the file” is not cosmetic — it changes the execution mode of the whole script. And if you place USER too early, the Dockerfile starts breaking on RUN mkdir ..., apt-get ..., and other system-level steps: non-root can no longer do them “by default.”

3. The USER instruction: behavior

When you first see USER appuser in a Dockerfile, it is tempting to think of it as a “security checkbox”: you set it and everything becomes correct. But Docker does not work by “checkboxes”; it works by “one line changes the behavior of the following lines.” So it is worth understanding the mechanics, otherwise USER will feel like a magic spell.

Fact #1: USER sets the default user (and optionally group) for all subsequent Dockerfile instructions that execute inside the image during the build stage, and for the process that will be launched by the container. Docker says this directly: by default the user is root, and the Dockerfile instruction USER sets the user for the container’s first process; at container start, this can be overridden with docker run -u ....

Fact #2: USER does not create a user. If there is no entry for appuser in the container, then USER appuser will either fail at build/run time or lead to very strange effects. That is why we first create a user with RUN adduser ... or RUN useradd ... (depending on the base image), and only then use USER.

Fact #3: USER is specifically about process permissions. It does not “protect secrets,” does not “fix the network,” and does not “make the image smaller.” It does one thing: the process is no longer root, and that changes everything related to files, ports (for example, below 1024), the ability to write to directories, and so on. So USER is often a turning point: the image starts behaving “honestly” instead of “however it happens to.”

WORKDIR /app              # Working directory inside the image
RUN mkdir -p /app/tmp     # Create the directory before switching users, while root permissions are still available
USER appuser              # All subsequent instructions run as appuser (and so does the container process itself)
RUN echo "hello" > /app/tmp/test.txt  # Will fail during the build if appuser does not have write permissions

The important thing here is that RUN echo ... will run already as appuser. If appuser does not have permission to write to /app/tmp, the Dockerfile will fail right during the build. That is a normal signal, not “Docker being weird.” It just finally started executing the script the way you described it.

4. Non-root user: name and UID

When we say “run as non-root,” beginners usually have two questions. First: “Okay, but which user?” Second: “Why does everyone around me write these weird numbers like 10001?” Both questions are valid, because in containers a user name is often just a convenient label, while the real identity in the filesystem and in processes is the UID.

UID is the numeric user identifier. In Linux, this is not some optional detail — it is how the system ultimately understands who owns a file and who has which permissions. That is why Dockerfiles often do this: define the UID with ARG, create a user with that UID, and then run the process as that user. This gives predictability: you do not depend on which users already exist in the base image or which UID they happen to have. An example of this approach with ARG UID=10001, adduser ... --uid "${UID}", and then USER appuser appears in standard Dockerfile guides for containerizing Java applications.

Why is the UID often not 1000, but 10001 or something similar? Because 1000 on Linux hosts is often already taken by the first “regular” user, and you do not want accidental overlaps and unexpected effects, especially when bind mounts are involved. In addition, a “high UID” is perceived as a “service user,” not as “some guy named Vasya who somehow ended up inside the container.” That is not magic, it is discipline.

Here is a minimal and readable fragment that creates a non-root user in Debian/Ubuntu-like images:

# Pin the UID so we do not depend on which users/UIDs already exist in the base image
ARG UID=10001
RUN adduser --disabled-password --gecos "" \
    --home "/nonexistent" --shell "/sbin/nologin" \
    --no-create-home --uid "${UID}" appuser  # Create a user without a home directory and without an interactive shell
USER appuser                                 # From here on, commands run and the container starts as non-root

The meaning of the flags here is simple. We create a user without a password, without a home directory (we do not need a “real interactive login”), and with a shell that does not imply logging into the system. This is not “security on max,” just a normal baseline: the process only needs to exist and run Java.

And one important point: even if you use the exec-form ENTRYPOINT (and you already do, because we do not want to lose signals and graceful shutdown), switching to non-root does not отмена that rule. On the contrary, ENTRYPOINT remains the same stable way to start the app, and USER simply changes who starts that process. Docker is also very explicit about signals and PID 1: shell-form ENTRYPOINT does not pass signals correctly, exec-form does.

5. Non-root in the service Dockerfile

Here it is enough to fix two things: create a non-root user and explicitly switch to it before starting the application. That is already enough for the container to stop living on root cheats. And file write permissions for specific directories will still need to be prepared separately — and that is exactly what non-root exposes quickly.

Here we have a regular Spring Boot service with an executable jar that starts through java -jar. That means a JRE-based image is enough in the runtime stage. And so that the Dockerfile does not spread into app.jar here and application.jar there, it is useful to keep one jar name and one stage name from the start: runtime and /app/application.jar.

I will show a very compact runtime stage that already includes a non-root user. This is a skeleton that is easy to recognize and easy to move between projects:

FROM eclipse-temurin:25-jre-jammy AS runtime                # Final runtime stage: the minimum needed to run

ARG UID=10001                                               # Pin the service user's UID
RUN adduser --disabled-password --gecos "" \
    --home "/nonexistent" --shell "/sbin/nologin" \
    --no-create-home --uid "${UID}" appuser                # Create a non-root user

WORKDIR /app                                                # Application directory inside the container
COPY --from=builder --chown=appuser:appuser /build/build/libs/*.jar /app/application.jar
USER appuser                                                # The application will run as non-root
ENTRYPOINT ["java", "-jar", "/app/application.jar"]         # Exec form: correct signals and graceful shutdown

There are several important highlights here, even if the code looks “too simple.” First, USER appuser is placed at the end, right before ENTRYPOINT. That is intentional: we do everything that needs root permissions before the switch. Second, ENTRYPOINT stays in exec-form. Non-root does not отмена the basic discipline of starting the app: the process still needs to receive signals properly and exit without a shell wrapper.

By default, COPY creates files owned by uid/gid 0 unless you use --chown. That is why, in the final template, it is more convenient to copy the jar with the correct owner right away and prepare writable directories separately. At this stage, the important thing is to pin down the fact itself: the process is not running as root. As soon as the service needs to write to a directory, permissions for that directory must be prepared explicitly — and non-root quickly checks that honestly.

6. Verifying non-root startup

A very human mistake is to read the Dockerfile, see USER appuser, and relax. The brain likes checkboxes: if the line is there, then everything is fine. But the container world does not like faith; it likes verifiable facts. So after introducing the non-root rule, there should be a short verification: who is actually executing the process inside the running container, and what Docker considers to be the “container user.”

One quick way is to enter the container and run id or whoami. Another is to inspect the container configuration with docker inspect. In practice, it is useful to do both: inspect tells you what is written in the config, exec tells you what is really happening inside.

Example commands — substitute your own container and image names; the idea is shown here:

# Start the container in the background (make sure you are running the freshly built image)
docker run -d --name catalog-app -p 8080:8080 catalog-service:local

# Check the UID/GID of the real process inside the running container
docker exec catalog-app id          # uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)

# See what Docker considers the "container user" in the configuration
docker inspect --format='{{.Config.User}}' catalog-app   # appuser

# Also check the user for PID 1 and the launch command (for self-check)
docker exec catalog-app ps -o user,pid,cmd | head -n 2   # USER   PID CMD

If you see uid=0(root), then something went wrong. Either the Dockerfile is not the one you think, or you started an old image, or you overrode the user in Compose/CLI. And yes, overriding is possible: docker run -u ... can start the container as a different user than the one specified in the Dockerfile, and that is also officially considered a normal mechanism. But for our final template, the idea is the opposite: the Dockerfile should be “secure by default,” not “secure when the developer is in the right mood.”

A useful mini-check “for contrast”: almost any base image without USER will be root. For example:

# Contrast: without the USER instruction, the container almost always starts as root (uid=0)
docker run --rm ubuntu:24.04 id     # uid=0(root) gid=0(root) groups=0(root)

That is not an “Ubuntu error,” it is just the default: containers start as root until you explicitly do otherwise.

7. Non-root and file permissions

Now for an important psychological warning, so you do not think Docker has “broken.” As soon as you move runtime to non-root, the application may suddenly start failing with AccessDeniedException, “cannot create file,” “cannot write export,” “cannot create directory.” And that does not mean non-root is a bad idea. It means you have finally stopped driving around the city in a tank and switched to a regular car that has traffic rules.

Non-root simply makes the model honest. If the service needs to write files, it should have an explicit writable path, and that path must be prepared. If the service receives a bind mount from the host, then the host filesystem’s quirks “come along” into the container too. This is especially painful on export directories: the application wants to write a CSV, but the directory either belongs to root, is read-only, or was created on the host in a way that the container UID does not have permission to use.

That is not a reason to go back to root. It is a reason to define the writable path, the owner, and the permissions explicitly instead of hoping the process can “somehow” write the file.

8. Typical non-root mistakes

Mistake #1: create the user in the builder stage and forget about the runtime stage.
This is the classic multi-stage build trap. You add RUN adduser ... appuser somewhere in the builder, the build passes, you are happy, and then the runtime stage starts from a different FROM, and that user simply does not exist there. The result: either the container starts as root (if USER is not specified), or it fails (if USER appuser is specified but the user does not exist). The fix is simple: remember that the final stage is a separate “world,” and it needs to be checked separately.

Mistake #2: set USER appuser too early and then try to do root-only tasks.
If after USER you have RUN apt-get ..., RUN mkdir /something/system, RUN chown ..., or any command that requires privileges, the Dockerfile will start failing during the build. That is not “Docker being picky”; it is you describing a script where an ordinary user suddenly became an administrator. The correct order is almost always this: prepare the environment as root first, then use USER, and only then do what can be done by an ordinary user.

Mistake #3: assume that USER automatically solves all permission problems.
USER does not “solve” anything; it only defines reality. If your service writes to a directory owned by root, non-root will simply say “no” honestly. That is unpleasant, but it is great: better to see the problem in a training project and in local Docker than in someone else’s environment a week later.

Mistake #4: do not verify the actual user of the running container.
Until you run docker exec ... id and docker inspect ..., you do not actually know who is executing the process. You can accidentally start an old image, you can override the user in docker run -u, you can get a “mixed” result because of Compose changes. The Dockerfile is intent; verification is fact. Docker very clearly states that USER can be overridden when a container starts, so verification is not paranoia — it is normal hygiene.

Mistake #5: choose a UID “at random” and then be surprised by weird effects.
If you do not pin the UID, you start depending on the base image and on which users already exist there. If you do pin it but choose a popular one like 1000, you risk colliding with the host user in bind-mount scenarios. Yes, this does not always break the system, but sometimes it creates a very strange, hard-to-explain file reality. That is why templates often use a separate service UID (for example, 10001) and set it through ARG, as in standard Java containerization examples.

1
Zadanie
Docker for Spring, poziom 24, lekcja 0
Niedostępne
Budowanie obrazu z konfigurowalnym UID
Budowanie obrazu z konfigurowalnym UID
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION