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.yamland 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
dockergroup, 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.
docker run -d --name essai-env -e CLE_PAIEMENT=valeur-de-demonstration \ alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sleep 3600docker inspect -f '{{.Config.Env}}' essai-envdocker 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.
mkdir ~/caisse && cd ~/caissegit init -qmkdir secretsprintf 'secrets/\n' > .gitignoreopenssl rand -hex 16 > secrets/cle_paiement.txtchmod 600 secrets/cle_paiement.txtgit check-ignore -v secrets/cle_paiement.txt.gitignore:1:secrets/ secrets/cle_paiement.txtgit 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.txtThe 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.
docker compose up -ddocker 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.4s219e61b35899The 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.
docker compose config | grep -c -F -f secrets/cle_paiement.txtdocker inspect caisse-facturation-1 | grep -c -F -f secrets/cle_paiement.txtdocker compose logs | grep -c -F -f secrets/cle_paiement.txtdocker export caisse-facturation-1 | grep -a -c -F -f secrets/cle_paiement.txt0000In 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.
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.
docker compose exec -T curieux sh -c 'cat > /tmp/copie' < secrets/cle_paiement.txtdocker export caisse-curieux-1 | grep -a -c -F -f secrets/cle_paiement.txt1The 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.
| Command | Expected result |
|---|---|
curieux lists /run/secrets/ | failure: the folder does not exist, the service received nothing |
facturation writes to the file | failure: the mount is read-only |
facturation, under identity 65534, counts the bytes of the file | failure: the file belongs to your account, 600 mode |
the same command after chmod 644 | success: everyone can read the file |
Run the first two commands.
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 directorysh: can't create /run/secrets/cle_paiement: Read-only file systemThe 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.
docker compose exec facturation ls -ln /run/secrets/cle_paiementdocker 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_paiementsh: can't open /run/secrets/cle_paiement: Permission deniedRead 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.
chmod 644 secrets/cle_paiement.txtdocker compose exec --user 65534 facturation sh -c 'wc -c < /run/secrets/cle_paiement'33Account 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.
chmod 600 secrets/cle_paiement.txtWho 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.
docker run --rm -v "$PWD/secrets:/lecture:ro" \ alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a \ sh -c 'wc -c < /lecture/cle_paiement.txt'33The 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.
| Who | Can read the secret? | Through which path, and proof |
|---|---|---|
| the processes of the service whose identity can read the file | yes | the read-only mount: docker compose exec facturation ...; identity 65534 is refused under 600 permissions |
| another service of the stack | no | no mount: ls /run/secrets/ fails in curieux |
| the owner of the file, or root, on the host | yes | the file itself: cat secrets/cle_paiement.txt |
any account of the docker group | yes | a container started with docker run -v, as above |
| someone who pulls the image | no | the file is not in the image: the 0 count of docker export |
| whoever reads the logs | yes, if the application writes the secret | docker 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 host | yes | the 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 command | yes | ps -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.
docker compose exec facturation cat /tmp/lecture-au-demarragesha256sum secrets/cle_paiement.txt | cut -c1-12219e61b35899219e61b35899Now 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.
openssl rand -hex 16 > secrets/cle_paiement.txtdocker compose exec facturation sh -c 'sha256sum /run/secrets/cle_paiement | cut -c1-12'docker compose exec facturation cat /tmp/lecture-au-demarragedocker compose up -d790b63b5f8d4219e61b35899[+] up 2/2 ✔ Container caisse-curieux-1 Running 0.0s ✔ Container caisse-facturation-1 Running 0.0sRead 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.
docker compose up -d --force-recreate facturationdocker compose exec facturation cat /tmp/lecture-au-demarrage[+] up 1/1 ✔ Container caisse-facturation-1 Started 10.5s790b63b5f8d4Only 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.
openssl rand -hex 16 > secrets/nouveau.txtmv secrets/nouveau.txt secrets/cle_paiement.txtsha256sum secrets/cle_paiement.txt | cut -c1-12docker compose exec facturation sh -c 'sha256sum /run/secrets/cle_paiement | cut -c1-12'dd4b25cac524790b63b5f8d4The 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.
chmod 600 secrets/cle_paiement.txtdocker compose up -d --force-recreate facturationsha256sum secrets/cle_paiement.txt | cut -c1-12docker compose exec facturation sh -c 'sha256sum /run/secrets/cle_paiement | cut -c1-12'docker compose exec facturation cat /tmp/lecture-au-demarragedd4b25cac524dd4b25cac524dd4b25cac524The 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.
| Layer | After >, before any recreation | After mv, before the next recreation | After chmod and recreation |
|---|---|---|---|
| file on the host | 790b... | dd4b... | dd4b... |
| file mounted in the container | 790b... | 790b... (the old file) | dd4b... |
| value kept by the service | 219e... | 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 observe | Probable cause | What 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 exist | The source file does not exist at this path, relative to the folder of the compose.yaml | Create the file or fix file:, then run again |
No such file or directory in /run/secrets/ of a service | The service does not list the secret in its secrets: key | Add the name of the secret to this service, then recreate it |
Permission denied for a non-root application | The file belongs to another account and has 600 permissions | Give 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 replacement | The service was not recreated, or the file was renamed over the old one | Run docker compose up -d --force-recreate <service> and compare the digests |
| Authentication fails although the value is right | The file ends with a newline that the application compares | Write the secret with printf '%s' ..., or have the application strip this last character before the comparison |
The value appears in docker inspect | It went through environment: in addition to the secret | Remove the variable and read the file |
| The secret went into a commit | secrets/ was not ignored before git add | Remove 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.
cd ~/caissedocker compose downThen remove the image, then the folder. The secret is a random demonstration file, but erase it as you would a real one.
docker rmi alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9acd ~ && rm -r caisseAfter 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
-
A value passed through
environment:can be read withdocker inspect. A file-based secret leaves only the path of the file there, never the value. -
A secret is declared in the top-level
secrets:block, withfile:, and granted through thesecrets:key of a service. It appears in the container under/run/secrets/<name>, read-only. -
Ignore the secrets folder in
.gitignorebefore creating the file, and check it withgit check-ignore -v. A secret that entered the history must be renewed. -
An absence is proved with a control: count the value with
grep -c -F -f <file>in the configuration,docker inspect, the logs anddocker 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, becausepsprints the arguments. -
A service that does not list the secret does not receive it:
/run/secrets/does not exist for it. -
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,gidandmodeattributes of the long form change nothing for a secret with afile:source: Compose prints a warning and ignores them. -
A Compose secret is not encrypted. Root, the
dockergroup and the consuming service can read it, and nothing prevents the application from writing it in its logs. -
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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Startup, readiness and health
A new full quiz with random questions
Work again only on the questions you missed