Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 625
bjoern-nowak Avatar asked Oct 19 '25 11:10

bjoern-nowak


1 Answers

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.

like image 82
ErikMD Avatar answered Oct 22 '25 02:10

ErikMD



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!