Question

How to use docker compose secrets with a non-root user when a file is required

Situation

The current (07/2024) docker compose documentation states (falsely) that there is a long-syntax when using 'docker secrets' that can defines the name, uid, gid and mode of the mounted file

See example and documentaion:

services:
  frontend:
    image: example/webapp
    secrets:
      - source: server-certificate
        target: server.cert
        uid: "103"
        gid: "103"
        mode: 0440
secrets:
  server-certificate:
    file: ./server.cert

This configuration is valid but has no affect at all when using compose (works for docker swarm). There was an discussion (GitHub Docker Issue 9648) going on about it (showing the different implementations of this specification) but the documentation has not been fixed (GitHub Docker Issue 18907).

Result is a mounted secret (/run/secrets/<foobar>) with root:root (0:0) ownership and permission mode 400. Sidenote: /run/secrets/ is a read-only mounted filesystem.

Problem

When using docker compose secrets on an image which does come with a non-root user shipped.

services:
    nginx:
        image: nginxinc/nginx-unprivileged:1.27-alpine
        ports:
            - "8080:8080"
        secrets:
            - FOO_BAR_SECRET
secrets:
    FOO_BAR_SECRET:
        file: .foo.bar

Solution idea (question)

Is there another/better solution then creating a custom image (Dockerfile) which switches back to the 'root' user and defines a wrapping docker entrypoint script

FROM nginxinc/nginx-unprivileged:1.27-alpine

## switching to non-root user 'nginx' later in custom 'docker-entrypoint-wrapper.sh'
USER root

## one could argue to just use pre-installed 'runuser' instead, or install 'gosu'. For this example there is no strong argument for either
RUN apk update && apk add su-exec

## enables us to run commands on startup as 'root'
RUN mv /docker-entrypoint.sh /docker-entrypoint-original.sh
COPY docker-entrypoint-wrapper.sh /docker-entrypoint.sh
RUN chmod ug+x /docker-entrypoint.sh

that copies those root-exclusive secrets from the read-only filesystem mount elsewhere, updates the file owner to the desired non-root user and switches back to this user to execute the actual/original docker entrypoint script ?

#!/usr/bin/env sh
set -e

mkdir /run/secrets_ \
&& cp -r /run/secrets/* /run/secrets_ \
&& chown -R nginx:nginx /run/secrets_

# as mentioned in the Docker file: may use 'gosu' or 'runuser' instead
exec su-exec nginx /docker-entrypoint-original.sh "${@}"

Solution alternative (question) when a file is not required or just not an option

Qudos to dcendents (GitHub) pointing out this idea (on GitHub Keycloak Issue 10816, that one could 'export' the content of the secret file. Which indeed is not really desired security-wise hence you may not export it but just make it 'inline available' for the command.

#!/bin/sh

## find all secret files mounted by docker
for i in $(ls -1 /run/secrets)
do
    ## export secret file name as environment variable
    export "${i}"="$(cat /run/secrets/${i})"
done

## run actual command
exec "$@"

Research

There is one idea of having just an "simple" docker mount declaration with the desired ownership and permission mode but in that case one would be required to do it for every X different services and Y different secrets, which would lead to an "small" bloat of long repeating lines.

Restrictions/requirements on a solution

(A) I need or would like to have a general solution thats works for a Windows and Linux host. Scripting a chown on a Windows host may not be a way.

(B) The FOO_BAR_SECRET will be used by multiple services (it will be a wildcard TLS certificate) which all requires different UIDs.

 2  89  2
1 Jan 1970

Solution

 1

As mentioned in docker/compose#9648, docker compose now warns about the fact uid, gid, mode fields are not supported:

services:
  nginx:
    image: nginxinc/nginx-unprivileged:1.27-alpine
    ports:
      - "8080:8080"
    secrets:
      - source: foobar
        target: FOO_BAR_SECRET
        uid: "103"
        gid: "103"
        mode: 0440
    entrypoint: '/bin/sh -c "ls -Rhal /run/secrets/; cat /run/secrets/FOO_BAR_SECRET"'
secrets:
  foobar:
    file: .foo.bar
$ docker compose up

WARN[0000] secrets `uid`, `gid` and `mode` are not supported, they will be ignored 

Attaching to nginx-1
nginx-1  | /run/secrets/:
nginx-1  | total 12K    
nginx-1  | drwxr-xr-x    2 root     root        4.0K Jul 19 13:31 .
nginx-1  | drwxr-xr-x    1 root     root        4.0K Jul 19 13:31 ..
nginx-1  | -rw-r-----    1 1000     1000          18 Jul 19 13:15 FOO_BAR_SECRET
nginx-1  | cat: can't open '/run/secrets/FOO_BAR_SECRET': Permission denied
nginx-1 exited with code 1

The secret file is not readable, but note that on my workstation (Debian 12) the file is not owned by root:root, but has the same permissions as the source file:

$ ls -l .foo.bar

-rw-r----- 1 user user 18 juil. 19 15:15 .foo.bar

So it appears you have at least one way to address your issue (to be tested on your side):

Check the image's uid

Apply the uid locally

$ sudo chown 101:101 .foo.bar; sudo chmod 440 .foo.bar

Skip unsupported secret fields in docker-compose.yml

services:
  nginx:
    image: nginxinc/nginx-unprivileged:1.27-alpine
    ports:
      - "8080:8080"
    secrets:
      - source: foobar
        target: FOO_BAR_SECRET
    entrypoint: '/bin/sh -c "ls -Rhal /run/secrets/; cat /run/secrets/FOO_BAR_SECRET"'
secrets:
  foobar:
    file: .foo.bar
$ docker compose up

Attaching to nginx-1
nginx-1  | /run/secrets/:
nginx-1  | total 12K    
nginx-1  | drwxr-xr-x    2 root     root        4.0K Jul 19 13:44 .
nginx-1  | drwxr-xr-x    1 root     root        4.0K Jul 19 13:44 ..
nginx-1  | -r--r-----    1 nginx    nginx         18 Jul 19 13:15 FOO_BAR_SECRET
nginx-1  | TEST_VAR="VALUE!"
nginx-1 exited with code 0

Side-notes on Docker Engine for Linux vs. Docker Desktop

The above behavior may require directly using Docker Engine (and not Docker Desktop), as you confirmed in the comments.

Indeed, the bind-mount permissions are known to always-default-to-root:root with Docker Desktop (even on Linux), see:

⚠️ As another side note, if ever you install Docker Engine for GNU/Linux on a personal workstation, do not add your user account to the docker group as it is suggested in many online tutorials (sudo usermod -aG docker $USER), because this is risky. Just define an alias to avoid typing sudo docker explicitly but still imply sudo to get a a prompt asking for the sudoer password. For details, see this SO answer of mine.

2024-07-19
ErikMD

Solution

 0

Following the discussion in the comments of my previous answer, here is another solution, which should be cross-platform, and with a better SoC / DRY:

TL;DR: The OP initially suggested to use a Dockerfile that goes root, adds some layers, and so on, but I'd also suggest to use only one (generic) Dockerfile, and pass values either via build arguments (at build time), or via environment variables (at container run time), all specified in a concise way from the YAML conf file.

Consider this docker-compose.yml:

services:
  nginx:
    build:
      context: wrap-secret
      args:
        # original image
        image: nginxinc/nginx-unprivileged:1.27-alpine
        # original entrypoint file
        entrypoint: "/entrypoint.sh"
    environment:
      target_uid: 101
      target_gid: 101
    ports:
      - "8080:8080"
    secrets:
      - source: foobar
        target: FOO_BAR_SECRET
secrets:
  foobar:
    file: .foo.bar

With this wrap-secret/Dockerfile:

ARG image
FROM $image
USER root

# BEGIN TAKEN FROM https://github.com/tianon/gosu/blob/master/INSTALL.md
# ASSUMING $image is alpine-based
ENV GOSU_VERSION 1.17
RUN set -eux; \
    \
    apk add --no-cache --virtual .gosu-deps \
        ca-certificates \
        dpkg \
        gnupg \
    ; \
    \
    dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
    wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
    wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
    \
# verify the signature
    export GNUPGHOME="$(mktemp -d)"; \
    gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
    gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
    gpgconf --kill all; \
    rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
    \
# clean up fetch dependencies
    apk del --no-network .gosu-deps; \
    \
    chmod +x /usr/local/bin/gosu; \
# verify that the binary works
    gosu --version; \
    gosu nobody true
# END TAKEN FROM https://github.com/tianon/gosu/blob/master/INSTALL.md

WORKDIR /app
COPY wrap-secret-entrypoint.sh /app/wrap-secret-entrypoint.sh
ARG entrypoint
RUN chmod a+x /app/wrap-secret-entrypoint.sh \
  && sed -e 's@THE_ENTRYPOINT@'"${entrypoint}"'@' -i /app/wrap-secret-entrypoint.sh
# we might want to replace sed with a perl oneliner or so
ENTRYPOINT ["/app/wrap-secret-entrypoint.sh"]

And this script wrap-secret-entrypoint.sh:

#!/usr/bin/env sh
set -e
entrypoint="THE_ENTRYPOINT"
[ -n "$target_uid" ]
[ -n "$target_gid" ]
# we might also pass target_mode…
mkdir /run/secrets_
cp -a /run/secrets/* /run/secrets_/
chown -R "$target_uid:$target_gid" /run/secrets_
# we might use 'su-exec' or 'runuser' instead
exec gosu "$target_uid:$target_gid" "$entrypoint" "$@"

Then run docker compose up --build.

What do you think?

Side-note:

In your original post, you had written something like:

set -e
cmd1 && cmd2

which is buggy, use ( cmd && cmd2 ) instead or cmd1 ; cmd2.

2024-07-19
ErikMD