Using unprivileged user in dockerized app

After ditching MacOS and moving to Linux I noticed that my dockerized app (in short: app) started to generate files owned by root user. First, it was annoying (limited access to logs etc), then I started to realize that’s something is wrong.

The answer seemed to be obvious - the owner of app process was root so the files generated by it were also owned by it. Yet, this didn’t happen back in the MacOS days. What changed?

Here, I should add a quick note about UID and GID. UID is an identifier of a user, GID of a group. Each user has a UID and GID of a primary group. Every file has also it’s own UID and GID. This defines who owns a file.

So what changed? Well, apparently Docker on Linux transfers ownership of files created in a container.

Since default user in Docker containers is usually root everything created by the application is owned by it. This could’ve explained why suddenly I had files owned by root.

First, I wanted to change something in Docker (hey, it worked on MacOS!). Sadly it’s not possible. The solution seems to be simple - create an unprivileged user (in Docker image) and make sure that it has the same UID and GID as the user who owns files on the host machine.

This should be a simple task. It’s not.

General structure of Dockerfile

I decided to split my Dockerfile into two sections.

System section is responsible for installing system wide dependencies (eg. git, musl, glib). It’s used also to set up proper permissions for an app user.

App section is responsible for installing project dependencies (eg. composer install, npm i or yarn), or generating app-specific files (eg. generating API docs).

FROM alpine

### System

# Create app user
RUN mkdir -p /usr/src/app \
    && addgroup app \
    && adduser -h /usr/src/app -G app -D app

### App
WORKDIR /usr/src/app
USER app

COPY --chown=app . /usr/src/app/
RUN npm i

CMD ["my-app"]

You probably noticed --chown=app near COPY statement. By default, added files are owned by root. --chown=app will change the owner of created files.

Matching UID and GID for dev environment

Having unprivileged user won’t solve entirely permissions problem. app user will have pairs of UID and GID assigned by the system. It’s possible that they won’t match UID and GID of a user owning files on the host machine.

This means that files generated on a container will have still invalid ownership.

$ docker run --rm -it -v "$PWD:/host" alpine sh
/ # adduser -D -u 9000 app
/ # touch /host/test
/ # chown app /host/test
/ # exit
$ ls -lah test
-rw-r--r-- 1 9000 root 0 01-08 19:14 test

Here, I’ve done a couple of things:

  • started Docker container with current directory mounted as /host
  • created app user with UID=9000
  • created /host/test file and owned it to app user
  • listed files on the host machine

As you can see, the created file is owned by an unknown user (hence the number instead of a name).

To fix that Docker needs to know your users UID and GID. This will ensure that files created by app user will belong to you on the host machine.

FROM alpine

ARG DOCKER_UID=1000
ARG DOCKER_GID=1000

### System

# Create app user
RUN mkdir -p /usr/src/app \
    && addgroup -g $DOCKER_GID app \
    && adduser -h /usr/src/app -u $DOCKER_UID -G app -D app

To build image on host machine run:

docker build \
    --build-arg DOCKER_UID=$(id -u) \
    --build-arg DOCKER_GID=$(id -g) \
    .

The user created inside a Docker image will have UID and GID matching your user. This way files created by the application user will be fully owned by you.

Using existing unprivileged users

Some images (eg. node) provide an unprivileged user (in case of node images it’s… node).

If you want to use it, make sure that it has matching UID and GID. You can change them using usermod and groupmod.

FROM node:alpine

ARG DOCKER_UID=1000
ARG DOCKER_GID=1000

RUN apk add --no-cache -t .deps shadow \
    && usermod -u $DOCKER_UID node \
    && groupmod -g $DOCKER_GID node \
    && apk del .deps

“Fixing” ownership on running containers

This is a tricky problem. If you decide to change the default user in an app which is already deployed you will have to deal with files stored in volumes. Those files will be still owned by the previous user.

This can be fixed in one of two ways:

  1. manually change ownership of volume files - this requires access to the server; has to be done once
  2. “fix” ownership everytime a new container starts - can be done without access to the server; complicates things a bit

Here, I’ll focus only on the second step.

Docker has something called ENTRYPOINT. It’s a script which runs every time a command is run on the container. It’s executed after volumes are mounted so it gives a great opportunity to “fix” ownership of files.

The script has to be run by the root user. With USER app statement in Dockerfile it won’t be possible. It has to be removed. Unfortunately, without it the application will be run by the root. To prevent this we can use gosu which is an elegant alternative for sudo. It will make sure that application will be run by a selected user.

The entrypoint.sh, a simple three-liner, is responsible for changing ownership of all files in /usr/src/app and executing CMD as the app user.

#!/bin/sh
chown -R app:app /usr/src/app
exec gosu app "$@"

Copy this file to image and set as an entry point. Don’t forget to make it executable.

FROM alpine

RUN apk add --no-cache \
  --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing \
  gosu

COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

Access to reserved ports

Ports 0-1024 are reserved to privileged services. Unprivileged users can’t open them by default.

It’s possible to allow a selected binary to open such ports. To do this you need to use libcap.

apk add --no-cache libcap
setcap 'cap_net_bind_service=+ep' $(which my-app)

Problems with access to /dev/stdout

There’s an issue with writing to /dev/stdout and /dev/stderr (both descriptors are used by Docker to catch the output and store it in logs).

$ docker run --rm -it alpine sh
/ # adduser -D app
/ # su - app
bfa7868d4ff8:~$ ls / > /dev/stdout
-sh: can't create /dev/stdout: Permission denied

There are few ways to fix it. In my case they worked on the local machine, however, on Rancher it didn’t work out.

Unfortunately, this can lead to limited application output. In my case, I was unable to redirect output of supervisor process to stdout.

Surprisingly, my node application could write to stdout without any problems.

Summary

I didn’t mention any security concerns. I’m not an expert, yet as a rule of thumb, it’s better to run the application by an unprivileged user (this includes also dockerized apps). Make sure that, if possible, your app is owned by an unprivileged user.

If you decide to do so - make sure that application files are owned by the selected user and the application has correct access to things it requires.