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 sudo privileges who is a member of the docker group.

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)

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

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 the docker group 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. Run docker swarm init, or run the command on a manager.
  • secret not found when deploying a stack: a secret marked external: true must exist before docker stack deploy. Check with docker secret ls.
  • password authentication failed after rotation: the database still has the old password. Update it with ALTER USER as shown in Step 5, or check for a trailing newline in the secret.
  • Permission denied reading /run/secrets/...: you set a restrictive mode with a uid that does not match the user the container runs as. Check the user with docker 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.