Skip to content
Blog

The USER Directive and Secure Defaults

Containers run as root by default — how the USER directive, explicit UIDs, file ownership, and gosu change that, and what USER doesn't fix.

Published on • October 18, 2026

AI Assistant

A container with no USER instruction runs its processes as root — UID 0, inside the container’s namespaces, with root’s capabilities against the container’s filesystem. It’s the default nobody opts into consciously, and it’s the first thing auditors flag. The building best practices page states the rule plainly: if a service can run without privileges, use USER to change to a non-root user. This is about making that directive a secure default rather than an afterthought.

Why root-by-default is a problem

Root inside a container isn’t root on the host — namespaces, seccomp, and capabilities see to that. But it’s still the highest-value target in your image: a remote code execution bug in your app becomes read/write access to the entire container filesystem, installed secrets, and the application’s own capabilities. It also multiplies the cost of any kernel-level escape. Defense in depth starts by removing the privilege your workload doesn’t need, and the Dockerfile USER reference exists precisely to declare which identity your RUN, CMD, and ENTRYPOINT execute under.

Create the user properly

Start by creating the account in the Dockerfile, as the best-practices page shows:

RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres

Two details in that line are deliberate:

  • -r creates a system account — a low, conventional UID rather than an arbitrary high one.
  • --no-log-init works around a Go archive/tar bug where creating a user with a large UID fills /var/log/faillog with null bytes and exhausts disk. Debian/Ubuntu’s adduser wrapper doesn’t support this flag, so use useradd directly.

For anything reproducible, assign an explicit UID/GID. The docs are direct about why: users and images get a non-deterministic UID/GID — the “next” free number is handed out regardless of rebuilds — so a rebuilt image can silently change identity and break volume permissions:

RUN groupadd --gid 10001 app && \
    useradd --uid 10001 --gid app --no-log-init --create-home app

Numeric UIDs also sidestep name resolution entirely: USER 10001 needs no account lookup, and it’s what Kubernetes admission controllers typically check against runAsNonRoot.

Place USER where it belongs

USER applies to every subsequent RUN, CMD, and ENTRYPOINT. Package installation needs root, so the ordering pattern is fixed:

FROM node:22-alpine
WORKDIR /app

# root phase: install and build
RUN apk add --no-cache tini
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# hand ownership over, then drop privileges
RUN chown -R app:app /app

# non-root phase: everything that runs
USER app
EXPOSE 8080
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Three rules fall out of this:

  1. Set USER before the runtime instructions, never after CMD — CMD records the command, but the identity is whatever USER last set at build time.
  2. Don’t switch back and forth. The docs warn against frequent USER changes: each flip adds complexity and layers. One root phase, one non-root phase.
  3. Files copied before the USER line are root-owned. If your app writes anywhere, fix ownership explicitly.

Fix ownership without extra layers

COPY --chown hands files to the right owner at copy time — no extra chown layer at all:

COPY --from=builder --chown=app:app /app/dist ./dist

For directories the app must write to, create and chown them in a single RUN so the ownership change lands in the same layer as the creation:

RUN mkdir -p /app/data && chown app:app /app/data

A trailing RUN chown -R app:app /app on an already-copied tree works too, but it commits the whole tree a second time — with COPY --chown available, it’s the wrong tool for the initial handover.

Never install sudo

The best-practices docs are unambiguous: avoid installing or using sudo — it has unpredictable TTY and signal-forwarding behavior that breaks containers. If you need the common pattern of “initialize as root, then run the app as its owner,” use gosu in an entrypoint script — exactly what the Postgres official image does:

#!/bin/sh
set -e

if [ "$1" = 'postgres' ]; then
    chown -R postgres "$PGDATA"
    if [ -z "$(ls -A "$PGDATA")" ]; then
        gosu postgres initdb
    fi
    exec gosu postgres "$@"
fi

exec "$@"

Note the exec builtin: the application becomes PID 1 and receives SIGTERM directly — dropping privileges without breaking shutdown handling.

The image USER is a default, not a guarantee

Declaring USER sets the image’s default identity; it doesn’t stop a determined operator from overriding it:

docker run --rm -it myimage whoami   # whatever USER declared
docker run --rm -it -u 0 myimage whoami   # back to root

Compose and orchestrators can override it too — user: "10001:10001" in a service definition, or a pod security policy requiring runAsNonRoot. If root must be impossible rather than merely unlikely, enforce it at deploy time: Kubernetes securityContext.runAsNonRoot: true, Pod Security Standards at the restricted level, or Docker’s own rootless mode. And remember what USER alone doesn’t do: it changes the UID, but doesn’t set capabilities, seccomp/AppArmor profiles, or mount a read-only rootfs. Pair it with cap_drop, read_only: true, and the platform’s defaults.

A complete, defensible shape

# syntax=docker/dockerfile:1
FROM alpine:3.21 AS build
WORKDIR /src
COPY . .
RUN go build -o /out/app ./cmd/server

FROM alpine:3.21
RUN apk add --no-cache ca-certificates tini && \
    addgroup --gid 10001 app && \
    adduser --uid 10001 --disabled-password -G app app
WORKDIR /app
COPY --from=build --chown=app:app /out/app ./app
USER 10001
EXPOSE 8080
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["./app"]

Minimal base, system account with an explicit UID, artifact copied with ownership in-layer, USER 10001 before the runtime instructions, and an init as PID 1. A reviewer can see the security posture without reading a single line of application code — which is the whole point of a secure default.

Sources