Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Inject secrets, and know their limits

By updated

Improve this page

Logo Docker

A password written in environment: can be read with a single command, docker inspect. Compose offers something better: a secret, that is, a sensitive value that Compose mounts in the container as a file, and only in the services you grant it to. The container reads it in /run/secrets/, and the value appears neither in its variables nor in its image.

This lesson is for a beginner who can write a Compose stack with variables and volumes. You declare a secret, you give it to a single service, you prove with commands where the value is absent, then you replace it. You also learn what a Compose secret does not protect: it is not encrypted, and several people can still read it.

What you will learn

  • Declare a file-based secret in a compose.yaml and grant it to a single service.
  • Prove, by counting matches with commands, that the value appears neither in the rendered configuration, nor in the metadata, nor in the logs, nor in the container filesystem.
  • Predict which service reads the secret, under which identity, and why another service is refused.
  • Replace a secret and see that the service must be recreated for it to read the secret again.
  • Name what a Compose secret does not protect: root access, access of the docker group, and the absence of encryption.

Prerequisites

You need a Linux host with Docker Engine 29.9.2 and its Compose 5.6.1 plugin, installed from the official repository as shown in the lesson on installing Docker. The images are pinned by their digest. The openssl and git commands must exist on the host: if command -v openssl git does not print both paths, install them with sudo apt install openssl git.

You wrote a first stack with the lesson Describe an application with Compose. The lesson Interpolation and environment files showed you that a .env file and environment: are not suitable for secrets, and it announced this page. The lesson Persist the data of a recreated stack explains --force-recreate and the read-only mount, which you reuse here.

You work in a ~/caisse folder, which the lesson has you create. The folder name becomes the project name: the containers are called caisse-....

For secrets management in general (build, CI, SOPS), the lesson Docker secrets (in French) remains the reference. This page covers only the secret of a Compose stack, when the containers start.

Why should a secret not go in environment:?

Because the variables of a container can be read by anyone who can query Docker. Start a container with a demonstration variable, then read its metadata.

Terminal window
docker run -d --name essai-env -e CLE_PAIEMENT=valeur-de-demonstration \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sleep 3600
docker inspect -f '{{.Config.Env}}' essai-env
docker rm -f essai-env
[CLE_PAIEMENT=valeur-de-demonstration PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin]

Look at the first entry: the value is there, in clear text. It appears in docker inspect, in docker compose config when it comes from a .env file, and in the environment of every process of the container. A file-based secret answers this problem: Compose puts only the path of the file in the metadata, never its content.

How do you declare a secret and grant it to a single service?

A secret is declared in two places. The top-level secrets: block says where the value comes from. The secrets: key of a service lists the ones that service receives. A service that does not list it receives nothing.

First create the folder, the Git repository and the secret file. The secrets/ folder is ignored by Git before the file exists, so that it is never offered to a git add. The file gets the 600 mode: only you can read and modify it.

Terminal window
mkdir ~/caisse && cd ~/caisse
git init -q
mkdir secrets
printf 'secrets/\n' > .gitignore
openssl rand -hex 16 > secrets/cle_paiement.txt
chmod 600 secrets/cle_paiement.txt
git check-ignore -v secrets/cle_paiement.txt
.gitignore:1:secrets/ secrets/cle_paiement.txt

git check-ignore -v prints the rule that ignores the file: here line 1 of .gitignore. If the command prints nothing, Git would track the file, and you must fix the .gitignore before going any further.

Then write the compose.yaml. The facturation service receives the secret and reads its value once at startup: it keeps only a 12-character digest of it in /tmp/lecture-au-demarrage, then waits. The curieux service has no secrets: key.

services:
facturation:
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
command:
- sh
- -c
- sha256sum /run/secrets/cle_paiement | cut -c1-12 > /tmp/lecture-au-demarrage && exec sleep 3600
secrets:
- cle_paiement
curieux:
image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
command: ["sleep", "3600"]
secrets:
cle_paiement:
file: ./secrets/cle_paiement.txt

The compose.yaml file contains only the name of the secret and the path of its source. You can therefore commit it without exposing the value. In the container, the cle_paiement secret appears at /run/secrets/cle_paiement: the name of the secret becomes the name of the file.

In the command of facturation, sleep 3600 only keeps the container alive, and exec replaces the shell with sleep, which becomes the main process of the container.

Start the stack and check that the service did read the secret.

Terminal window
docker compose up -d
docker compose exec facturation cat /tmp/lecture-au-demarrage
[+] up 3/3
✔ Network caisse_default Created 0.0s
✔ Container caisse-facturation-1 Started 0.3s
✔ Container caisse-curieux-1 Started 0.4s
219e61b35899

The twelve hexadecimal characters printed are the digest that facturation computed at startup. Yours will be different, since the secret is random. Note them down: the section on replacement compares them with the new digest.

What does the container contain, and what does it not contain?

It contains the mounted file, and nothing else of the value. To prove it, count the lines that contain the value in four places where it could leak. Each command must print 0. The value is never written in a command: grep -F -f secrets/cle_paiement.txt reads the pattern directly from the file. A command argument can be read with ps by any account of the host, whereas a file in 600 mode cannot.

Terminal window
docker compose config | grep -c -F -f secrets/cle_paiement.txt
docker inspect caisse-facturation-1 | grep -c -F -f secrets/cle_paiement.txt
docker compose logs | grep -c -F -f secrets/cle_paiement.txt
docker export caisse-facturation-1 | grep -a -c -F -f secrets/cle_paiement.txt
0
0
0
0

In order, these counts cover the configuration that Compose renders, the metadata of the container, its logs and its exported filesystem. docker export produces an archive of the filesystem of the container: a file mounted from the host is not part of it, and that is what proves the secret is not copied into the container.

In the configuration rendered by docker compose config, the secret appears only as file: /home/<you>/caisse/secrets/cle_paiement.txt, a path, and target: /run/secrets/cle_paiement. The mounts of the container give the detail of what Compose recorded.

Terminal window
docker inspect -f '{{range .Mounts}}{{.Source}} -> {{.Destination}} (rw={{.RW}}){{end}}' caisse-facturation-1
/home/ansible/caisse/secrets/cle_paiement.txt -> /run/secrets/cle_paiement (rw=false)

The output gives the path on the host, the path in the container and rw=false: the file is mounted read-only. Only the path appears in the metadata, never the value.

A count of zero proves nothing if the probe cannot find the value. So build a control: copy the value into the filesystem of the curieux service, and repeat the same count on it. The value goes in through the standard input (-T disables the terminal, < sends the file), so it appears in no command line.

Terminal window
docker compose exec -T curieux sh -c 'cat > /tmp/copie' < secrets/cle_paiement.txt
docker export caisse-curieux-1 | grep -a -c -F -f secrets/cle_paiement.txt
1

The 1 shows that the probe can detect a value copied into the container. It is what it would print if the secret had been written into the image during its build: the files of an image travel with it, to everyone who pulls it.

The secret of an image build relies on another mechanism, covered in the lesson Docker secrets (in French).

Which service can read the secret, and under which identity?

In the stack, only the service you grant it to can read it, and only with an identity that the permissions of the file allow. Four commands test this. Before running them, guess for each one whether it succeeds: the table gives the expected answer, so read it after guessing.

CommandExpected result
curieux lists /run/secrets/failure: the folder does not exist, the service received nothing
facturation writes to the filefailure: the mount is read-only
facturation, under identity 65534, counts the bytes of the filefailure: the file belongs to your account, 600 mode
the same command after chmod 644success: everyone can read the file

Run the first two commands.

Terminal window
docker compose exec curieux ls /run/secrets/
docker compose exec facturation sh -c 'echo x >> /run/secrets/cle_paiement'
ls: /run/secrets/: No such file or directory
sh: can't create /run/secrets/cle_paiement: Read-only file system

The first error proves that a service that is not listed does not receive the secret, and the second that even the consuming service cannot modify it.

Move on to the permissions. The file is mounted from the host, so its owner and its mode are those of the host. ls -ln prints the numeric identifier of the owner. The service runs as root by default, but the --user option of exec makes it act under identity 65534, an account with no particular rights.

Terminal window
docker compose exec facturation ls -ln /run/secrets/cle_paiement
docker compose exec --user 65534 facturation sh -c 'wc -c < /run/secrets/cle_paiement'
-rw------- 1 1001 1001 33 Nov 2 16:46 /run/secrets/cle_paiement
sh: can't open /run/secrets/cle_paiement: Permission denied

Read the first line: the owner is 1001, the numeric identifier of the host account that created the file (yours may differ, id -u prints it), and the mode is rw-------. In the container, the file keeps the owner, the mode and the date it has on the host. The second line shows that a process that is neither root nor the owner is refused: a non-root application in your service would therefore fail at startup with this same error.

The size column shows 33 bytes: the 32 hexadecimal characters plus a newline, which openssl rand adds. An application that compares the secret with a value must take it into account.

Fix it by opening read access to everyone, then replay the command.

Terminal window
chmod 644 secrets/cle_paiement.txt
docker compose exec --user 65534 facturation sh -c 'wc -c < /run/secrets/cle_paiement'
33

Account 65534 can now read the file, but every account of the host can read it too. It is a trade-off: the right setting depends on the identity under which the application runs. If you know its identifier, give it ownership of the file rather than opening it to everyone.

You might want to set this from the compose.yaml. The long form of a secret does accept the uid, gid and mode attributes, but for a secret whose source is a file:, Compose ignores them. It reports this with three warnings when docker compose up -d starts (measured with Compose 5.6.1, for uid: "65534", gid: "65534" and mode: 0400):

level=warning msg="service \"facturation\": secrets.cle_paiement.gid: gid is not supported outside Swarm mode and will be ignored"
level=warning msg="service \"facturation\": secrets.cle_paiement.mode: mode is not supported outside Swarm mode and will be ignored"
level=warning msg="service \"facturation\": secrets.cle_paiement.uid: uid is not supported outside Swarm mode and will be ignored"

Compose simply mounts the file from the host, whose permissions remain those of the host: ls -ln in the container still prints owner 1001 and the mode of the file on the host, whatever uid, gid and mode say. The setting is therefore made on the file itself.

Put the strict permissions back before continuing.

Terminal window
chmod 600 secrets/cle_paiement.txt

Who can still read the secret?

More people than the consuming service. A Compose secret is a file of the host mounted in a container: it is not encrypted, and the people who administer the machine can read it. Unlike the secrets of a Docker Swarm (in French) cluster, which the cluster stores encrypted, here the file stays in clear text on the disk.

To see it for yourself, read the secret from the host, without going through the service: any account able to start a container can mount the secrets/ folder in another container. This command requires only membership of the docker group.

Terminal window
docker run --rm -v "$PWD/secrets:/lecture:ro" \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a \
sh -c 'wc -c < /lecture/cle_paiement.txt'
33

The 33 proves that this container, which does not belong to your stack, read the file. The next table draws up the trust boundary, that is, the list of those who can read the value. Each line can be checked with a command from this lesson.

WhoCan read the secret?Through which path, and proof
the processes of the service whose identity can read the fileyesthe read-only mount: docker compose exec facturation ...; identity 65534 is refused under 600 permissions
another service of the stacknono mount: ls /run/secrets/ fails in curieux
the owner of the file, or root, on the hostyesthe file itself: cat secrets/cle_paiement.txt
any account of the docker groupyesa container started with docker run -v, as above
someone who pulls the imagenothe file is not in the image: the 0 count of docker export
whoever reads the logsyes, if the application writes the secretdocker compose logs | grep -c -F -f secrets/cle_paiement.txt is 0 here, because facturation writes nothing
whoever backs up or snapshots the disk of the hostyesthe file secrets/cle_paiement.txt is in clear text in any copy or snapshot of the disk
any account of the host, if the value is written in a commandyesps -eo args prints the arguments of a running command, and docker events prints those of an exec (exec_create event)

Remember the logs line and the last one: Compose limits where the value is, not what the application does with it. A service that writes its secret in its logs leaks it, despite the mounted file. The same goes for a value written in a command: this is why the lesson always reads the secret through a file or the standard input. To go further than this boundary (encrypt at rest, distribute to a fleet), dedicated secrets management is the job of a tool such as Infisical (in French), not of Compose.

How do you replace a secret?

By changing the file, then recreating the service that consumes it. Compose does not restart a service when a secret file changes. Three measurements show it. First read the digest that the service kept at startup, and the one of the current file: they must be identical.

Terminal window
docker compose exec facturation cat /tmp/lecture-au-demarrage
sha256sum secrets/cle_paiement.txt | cut -c1-12
219e61b35899
219e61b35899

Now replace the value. The > redirection rewrites the same file, with the same 600 permissions. Then read three things: the digest that the container now computes on the mounted secret, the one the service had kept, and what docker compose up -d decides to do.

Terminal window
openssl rand -hex 16 > secrets/cle_paiement.txt
docker compose exec facturation sh -c 'sha256sum /run/secrets/cle_paiement | cut -c1-12'
docker compose exec facturation cat /tmp/lecture-au-demarrage
docker compose up -d
790b63b5f8d4
219e61b35899
[+] up 2/2
✔ Container caisse-curieux-1 Running 0.0s
✔ Container caisse-facturation-1 Running 0.0s

Read the gap. The mounted file already shows the new value 790b..., without any recreation, because > rewrites the same file. But the service keeps the old digest 219e..., because it read the secret only at startup. And up -d answers Running: it sees no reason to recreate a service whose configuration file has not changed. The replacement therefore had no effect on the application. Force the recreation of the one service concerned.

Terminal window
docker compose up -d --force-recreate facturation
docker compose exec facturation cat /tmp/lecture-au-demarrage
[+] up 1/1
✔ Container caisse-facturation-1 Started 10.5s
790b63b5f8d4

Only facturation is recreated, and the digest it kept is now the one of the new secret. Remember the gesture: replace the file, then recreate the consuming services, and check the digest.

The 10.5 s displayed include the roughly ten seconds that Docker waits before killing the old container: sleep, the main process of the container, ignores the stop request. The lesson Lifecycle, PID 1 and clean stop explains this delay.

Recreating the service is not always enough to change the real password. A database that creates its password on first start does not change it by reading the secret again, because it uses it only at initialization: the lesson Persist the data of a recreated stack explains why.

Why does the container still see the old content after an mv?

Many editors and tools do not write into the file: they create a new one and rename it over the old one. Docker mounts the original file, not its name, so after a rename the container keeps seeing the old content. Reproduce it with mv.

Terminal window
openssl rand -hex 16 > secrets/nouveau.txt
mv secrets/nouveau.txt secrets/cle_paiement.txt
sha256sum secrets/cle_paiement.txt | cut -c1-12
docker compose exec facturation sh -c 'sha256sum /run/secrets/cle_paiement | cut -c1-12'
dd4b25cac524
790b63b5f8d4

The host holds the value dd4b..., and the container still sees 790b.... The renamed file also lost its 600 permissions: it carries those of a new file, rw-rw-r-- on the test machine (ls -l secrets/cle_paiement.txt). The fix restores the permissions, then recreates the service, which then mounts the file again by its name. The three digests must become identical again.

Terminal window
chmod 600 secrets/cle_paiement.txt
docker compose up -d --force-recreate facturation
sha256sum secrets/cle_paiement.txt | cut -c1-12
docker compose exec facturation sh -c 'sha256sum /run/secrets/cle_paiement | cut -c1-12'
docker compose exec facturation cat /tmp/lecture-au-demarrage
dd4b25cac524
dd4b25cac524
dd4b25cac524

The table sums up the three layers followed since the start of the section: the file on the host, the file as the container sees it, and the value the service kept at startup. Only the last row describes what the application really uses.

LayerAfter >, before any recreationAfter mv, before the next recreationAfter chmod and recreation
file on the host790b...dd4b...dd4b...
file mounted in the container790b...790b... (the old file)dd4b...
value kept by the service219e...790b...dd4b...

Whether the file is rewritten or renamed, the rule is the same: do not trust the mounted file, recreate the service.

What should you do when the secret is not read as expected?

Start from what you see, then check the mounted file before suspecting the application. The table links the errors met in this lesson to their cause.

What you observeProbable causeWhat you do
docker compose up -d stops on invalid mount config for type "bind": bind source path does not exist: <path>, preceded by secret file <project>_cle_paiement does not existThe source file does not exist at this path, relative to the folder of the compose.yamlCreate the file or fix file:, then run again
No such file or directory in /run/secrets/ of a serviceThe service does not list the secret in its secrets: keyAdd the name of the secret to this service, then recreate it
Permission denied for a non-root applicationThe file belongs to another account and has 600 permissionsGive ownership of the file to the identity of the application, or open read access knowing the trade-off
The application keeps the old value after a replacementThe service was not recreated, or the file was renamed over the old oneRun docker compose up -d --force-recreate <service> and compare the digests
Authentication fails although the value is rightThe file ends with a newline that the application comparesWrite the secret with printf '%s' ..., or have the application strip this last character before the comparison
The value appears in docker inspectIt went through environment: in addition to the secretRemove the variable and read the file
The secret went into a commitsecrets/ was not ignored before git addRemove the file from the index with git rm --cached, and consider the value compromised: it stays in the history, so it must be renewed

For the first row of the table, the failure happens after the creation of the network: no container exists, but the caisse_default network stays in place until the next docker compose down.

The last row is not fixed by a new commit: a secret that entered the history of a repository remains readable by anyone who holds the repository, so it must be renewed. The renewal follows the gesture described in "How do you replace a secret?".

How do you clean everything up after the demonstration?

The demonstration leaves two containers, one network, the ~/caisse folder with its Git repository and the secret file, and one image. The docker compose down command removes the containers and the network. There is no volume to remove.

Terminal window
cd ~/caisse
docker compose down

Then remove the image, then the folder. The secret is a random demonstration file, but erase it as you would a real one.

Terminal window
docker rmi alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
cd ~ && rm -r caisse

After that, docker ps -a shows no caisse-... container, docker network ls shows no caisse_default network, and ls ~/caisse answers that the folder no longer exists.

Practice

The lab gives you an image that reads a secret file at startup, a demonstration token outside the build context, and no running service. You write the compose.yaml that declares the secret and grants it to a single service, read-only, with no environment variable and no workaround mount. A second service must run without seeing the secret.

The validation looks at the state of the machine, not at the commands you typed. It checks that the value is absent from the metadata, the rendered configuration, the logs and the Git history, without ever printing it. It checks that the digest read at startup matches the file, then briefly replaces the token, recreates the services and restores the original.

Key points

  1. A value passed through environment: can be read with docker inspect. A file-based secret leaves only the path of the file there, never the value.

  2. A secret is declared in the top-level secrets: block, with file:, and granted through the secrets: key of a service. It appears in the container under /run/secrets/<name>, read-only.

  3. Ignore the secrets folder in .gitignore before creating the file, and check it with git check-ignore -v. A secret that entered the history must be renewed.

  4. An absence is proved with a control: count the value with grep -c -F -f <file> in the configuration, docker inspect, the logs and docker export, and copy the value into a container to check that the probe finds it. Always read the value from a file or the standard input, never in a command, because ps prints the arguments.

  5. A service that does not list the secret does not receive it: /run/secrets/ does not exist for it.

  6. The owner and the permissions of the host file apply in the container. A non-root application needs a file that its identity can read, which costs openness to the other accounts of the host. The uid, gid and mode attributes of the long form change nothing for a secret with a file: source: Compose prints a warning and ignores them.

  7. A Compose secret is not encrypted. Root, the docker group and the consuming service can read it, and nothing prevents the application from writing it in its logs.

  8. Replacing a secret is not enough: Compose does not recreate the service, and a file renamed over the old one is not seen again by the container. Recreate with docker compose up -d --force-recreate <service> and compare the digests.

Knowledge check

Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn