Passing passwords to containers through environment variables is convenient but leaky: they show up in docker inspect, in process listings inside the container, in crash dumps and in logs that print the environment. Docker secrets deliver sensitive values as files mounted in memory inside only the containers that need them. In this tutorial you will create secrets in Docker Swarm, use one to run PostgreSQL without a plain-text password, deploy a stack that consumes secrets, rotate a secret without downtime, and use the related features for Docker Compose and image builds.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
- Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A non-root user with
sudoprivileges who is a member of thedockergroup.
Swarm secrets require Swarm mode. A single node is enough for this tutorial; if you already run a multi-node cluster, run the commands on a manager.
How Docker secrets work
- A secret is created on a Swarm manager and stored in the Raft log, which is encrypted at rest on every manager.
- It is sent over the cluster's mutual TLS connections only to nodes that run a task of a service granted access to it.
- Inside the container it appears as a file under
/run/secrets/, on an in-memory filesystem. It is never written to the worker's disk and is removed when the task stops. - Secrets are immutable. You cannot change the value of an existing secret; you create a new one and switch services to it.
- The maximum size of a secret is 500 KB, enough for passwords, API tokens, TLS keys and small configuration files.
For non-sensitive files, Swarm has the sibling feature docker config, which works the same way but is not meant for confidential data.
Step 1 - Enabling Swarm mode
If the node is not part of a Swarm yet, initialize one. On a server with several IP addresses, pass the one other nodes should use with --advertise-addr:
docker swarm init --advertise-addr your_server_ip
Confirm Swarm is active:
docker info --format '{{.Swarm.LocalNodeState}}'
active
Step 2 - Creating secrets
docker secret create reads the value from a file, or from standard input when you pass - as the file name. Generate a random database password and store it directly as a secret, so the value never appears on the command line or in your shell history:
openssl rand -base64 32 | tr -d '\n' | docker secret create db_password -
tr -d '\n' removes the trailing newline. Many applications read the file byte for byte, and a newline at the end of a password is a common source of failed logins.
Create a second secret from an existing file, for example an API key you received from a provider:
printf '%s' 'your_api_key' > /tmp/api_key.txt
docker secret create api_key /tmp/api_key.txt
shred -u /tmp/api_key.txt
List the secrets:
docker secret ls
ID NAME DRIVER CREATED UPDATED
7x2k9m4p1q8w3e6r5t0y2u4i1 api_key 5 seconds ago 5 seconds ago
f3g8h1j6k2l9z4x7c0v5b3n8m db_password 20 seconds ago 20 seconds ago
You can inspect metadata, but Docker never returns the value of a secret through the CLI or the API:
docker secret inspect --pretty db_password
ID: f3g8h1j6k2l9z4x7c0v5b3n8m
Name: db_password
Driver:
Created at: 2026-09-25 10:15:02.118 +0000 utc
Updated at: 2026-09-25 10:15:02.118 +0000 utc
The only way to read a secret is from inside a container that has been granted it. Keep a copy of important values in your password manager if you need them outside the cluster.
Step 3 - Using a secret in a service
The official PostgreSQL image, like MySQL, MariaDB and many others, accepts _FILE variants of its configuration variables. Instead of POSTGRES_PASSWORD, you pass POSTGRES_PASSWORD_FILE with the path of the secret file:
docker service create \
--name db \
--secret db_password \
--env POSTGRES_PASSWORD_FILE=/run/secrets/db_password \
--env POSTGRES_USER=app \
--env POSTGRES_DB=app \
postgres:17
Wait until the service shows 1/1 replicas:
docker service ls --filter name=db
Find the container and confirm the secret is mounted as a file:
docker exec $(docker ps -q -f name=db.1) ls -l /run/secrets/
total 4
-r--r--r-- 1 root root 44 Sep 25 10:16 db_password
Now check what an attacker with access to docker inspect would see. The environment contains the path, not the password:
docker service inspect --format '{{json .Spec.TaskTemplate.ContainerSpec.Env}}' db
["POSTGRES_PASSWORD_FILE=/run/secrets/db_password","POSTGRES_USER=app","POSTGRES_DB=app"]
By default the file is owned by root and readable by all users in the container. If your image runs as a non-root user, you can tighten this and change the file name with the long --secret syntax:
--secret source=db_password,target=postgres_password,uid=999,gid=999,mode=0400
With that option, the file appears at /run/secrets/postgres_password, readable only by UID 999, which is the postgres user in the official image.
Remove the test service before continuing:
docker service rm db
Step 4 - Using secrets in a stack file
For real deployments, declare secrets in a stack file. Secrets created beforehand with docker secret create are marked as external, so the stack file contains no sensitive values and can live in Git.
mkdir -p ~/stacks/app && cd ~/stacks/app
nano stack.yml
services:
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db-data:/var/lib/postgresql/data
deploy:
replicas: 1
adminer:
image: adminer
ports:
- "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: db
deploy:
replicas: 1
secrets:
db_password:
external: true
volumes:
db-data:
Deploy the stack:
docker stack deploy -c stack.yml app
Verify both services are running:
docker stack services app
ID NAME MODE REPLICAS IMAGE PORTS
a1b2c3d4e5f6 app_adminer replicated 1/1 adminer:latest *:8081->8080/tcp
g7h8i9j0k1l2 app_db replicated 1/1 postgres:17
Confirm PostgreSQL accepts the password from the secret by connecting over TCP from inside the container, reading the password from the same file:
docker exec -it $(docker ps -q -f name=app_db) \
sh -c 'PGPASSWORD="$(cat /run/secrets/db_password)" psql -h 127.0.0.1 -U app -d app -c "select 1;"'
?column?
----------
1
(1 row)
Warningport 8081 is published on all interfaces and Docker bypasses UFW for published ports. Adminer is here only to show a second service; remove it with
docker service rm app_admineronce you have tested the stack.
Step 5 - Rotating a secret
Because secrets are immutable, rotation means creating a new secret and swapping it into the service. Mounting the new secret at the same target path means the application configuration does not change.
For PostgreSQL there is an extra step: the _FILE variable is only read when the data directory is initialized for the first time, so a new file does not change the password stored in the database. Generate the new password into a shell variable, use it both for the new secret and for the database, then discard it:
NEW_PW="$(openssl rand -base64 32 | tr -d '\n')"
printf '%s' "$NEW_PW" | docker secret create db_password_v2 -
Update the password inside PostgreSQL. The value is passed as a psql variable and quoted by psql itself with :'pw', so it is not written to your shell history or a file:
docker exec -i $(docker ps -q -f name=app_db) psql -U app -d app -v pw="$NEW_PW" <<'SQL'
ALTER USER app WITH PASSWORD :'pw';
SQL
unset NEW_PW
ALTER ROLE
Now swap the secret in the service, keeping the file name the application reads:
docker service update \
--secret-rm db_password \
--secret-add source=db_password_v2,target=db_password \
app_db
Swarm restarts the task with the new file mounted at /run/secrets/db_password. Repeat the verification from Step 4; it should still return 1. Once every service uses the new version, remove the old secret. Docker refuses to delete a secret that a service still uses, which protects you from removing it too early:
docker secret rm db_password
Notethe next
docker stack deployuses whateverstack.ymldeclares. Update the stack file to referencedb_password_v2withtarget: db_passwordso a redeploy does not revert the rotation.
For applications that read a password from the file on every connection, rotation is simpler: create the new secret, update the service, then remove the old one.
Step 6 - Secrets in Docker Compose without Swarm
Docker Compose supports the same secrets syntax, so a Compose file written for secrets works in development and in Swarm. Without Swarm, though, a Compose secret is only a bind mount of a local file. It is not encrypted, and it only helps by keeping the value out of the environment and out of docker inspect.
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Create the file with restrictive permissions and keep it out of version control:
mkdir -p secrets
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
chmod 600 secrets/db_password.txt
echo "secrets/" >> .gitignore
Step 7 - Using secrets during image builds
Build-time credentials, such as a token for a private package registry, must never be passed with ARG or COPY, because both end up in the image history. BuildKit secret mounts expose the file only during a single RUN instruction.
In the Dockerfile, mount the secret where the tool expects it:
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
Pass the file when you build:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
Verify that no layer contains the file:
docker history --no-trunc myapp | grep -c npmrc
The only match is the RUN instruction text itself; the file content is not stored in any layer.
Protecting the Swarm itself
Secrets are only as safe as the managers that store them:
- Enable autolock. By default, the key that decrypts the Raft log is stored on each manager's disk. With autolock, a restarted manager needs an unlock key before it can read the secrets:
docker swarm update --autolock=true
Store the printed key in your password manager. After a Docker restart, run docker swarm unlock on that manager and paste the key.
- Limit who can run
docker. Membership of thedockergroup is equivalent to root: anyone in it can start a container that mounts any secret. Keep the group small. - Grant secrets per service. A service only receives the secrets listed for it, so do not attach every secret to every service.
Troubleshooting
Error response from daemon: This node is not a swarm manager: secrets need Swarm mode. Rundocker swarm init, or run the command on a manager.secret not foundwhen deploying a stack: a secret markedexternal: truemust exist beforedocker stack deploy. Check withdocker secret ls.password authentication failedafter rotation: the database still has the old password. Update it withALTER USERas shown in Step 5, or check for a trailing newline in the secret.Permission deniedreading/run/secrets/...: you set a restrictivemodewith auidthat does not match the user the container runs as. Check the user withdocker exec container_name id.
Conclusion
You created Swarm secrets without exposing their values, ran PostgreSQL with POSTGRES_PASSWORD_FILE, deployed a stack that references external secrets, rotated a password without changing application configuration, and kept build credentials out of image layers. Next, enable autolock on every manager, move remaining passwords out of environment variables in your stacks, and consider a dedicated secrets manager such as HashiCorp Vault or OpenBao if you need audit logs and dynamic credentials.
