Back to Home

FIFO for one-time Docker Swarm secrets

The article describes a method for protecting Docker Swarm secrets via named FIFO channels. Secrets are transferred once to the application, excluding access via docker exec. Implementation based on two containers with a shared tmpfs volume.

One-time Docker Swarm secrets: FIFO against docker exec
Advertisement 728x90

One-Time Secrets in Docker Swarm Using FIFO Pipes

Docker Swarm mounts secrets to /run/secrets/ as read-only tmpfs volumes. Anyone with docker exec access can read them anytime. The fix: use named pipes (FIFOs) for one-time secret delivery at container startup.

Problems with the Standard Approach

Secrets are stored in the Raft log on manager nodes, sent via mTLS to workers, and mounted into containers. Here's how it's defined in docker-compose.yml:

version: "3.8"
services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
secrets:
  db_password:
    external: true
  api_key:
    external: true

Reading in your app:

Google AdInline article slot
with open("/run/secrets/db_password") as f:
    db_password = f.read().strip()

Access via docker exec:

$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123

Access on the host:

sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123

Why Standard Workarounds Fail

  • rm /truncate/chmod: read-only tmpfs
  • umount: requires CAP_SYS_ADMIN
  • Environment variables: visible in /proc/1/environ, inherited by child processes
  • External managers (Vault): adds extra infrastructure

How FIFO Pipes Work

Named pipes have these key traits:

Google AdInline article slot
  • Writes block without a reader
  • Reads block without a writer
  • Data is consumed on first read
  • Subsequent reads hang

Demo:

$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe  # blocks
cat /tmp/my_pipe                    # secret_value, data gone
cat /tmp/my_pipe                    # hangs

Solution Architecture

Two containers share a tmpfs volume with FIFOs:

  • secret-injector: creates FIFOs, writes secrets, then exits
  • app: reads from FIFOs once, keeps secrets in memory only

The app container has no /run/secrets/.

Google AdInline article slot

Implementation

docker-compose.yml

version: "3.8"
services:
  secret-injector:
    image: alpine:3.19
    secrets:
      - db_password
      - api_key
    volumes:
      - secrets_pipe:/shared
    entrypoint: ["sh", "-c", "
      mkfifo /shared/db_password /shared/api_key &&
      cat /run/secrets/db_password > /shared/db_password &
      cat /run/secrets/api_key > /shared/api_key &
      wait
    "]
    deploy:
      restart_policy:
        condition: none
      resources:
        limits:
          memory: 16M
  app:
    image: myapp:latest
    volumes:
      - secrets_pipe:/shared:ro
    entrypoint: ["sh", "-c", "
      DB_PASSWORD=$(cat /shared/db_password) &&
      API_KEY=$(cat /shared/api_key) &&
      exec myapp
    "]
    depends_on:
      - secret-injector
volumes:
  secrets_pipe:
    driver: local
    driver_opts:
      type: tmpfs
      device: tmpfs
      o: size=1m,noexec,nosuid
secrets:
  db_password:
    external: true
  api_key:
    external: true

Execution Sequence

  • secret-injector:

- mkfifo /shared/db_password /shared/api_key

- cat /run/secrets/... > /shared/... (blocks)

- wait keeps container alive

  • app:

- DB_PASSWORD=$(cat /shared/db_password) → unblocks writer

- Data consumed from FIFO

  • secret-injector exits (restart_policy: none)
  • app runs with secrets in memory

What an Attacker Sees

In app:

$ docker exec -it <app_container> sh
$ ls /run/secrets/
ls: /run/secrets/: No such file or directory
$ cat /shared/db_password  # hangs
^C

In secret-injector:

$ docker exec -it <injector_container> sh
Error: container is not running

Hardening: Use File Descriptors

Skip environment variables. Pass via file descriptors (FDs):

Entrypoint:

#!/bin/sh
exec 3< /shared/db_password
exec 4< /shared/api_key
exec myapp --db-password-fd=3 --api-key-fd=4

Python:

import os

def read_secret_from_fd(fd_num: int) -> str:
    with os.fdopen(fd_num, 'r') as f:
        return f.read().strip()

db_password = read_secret_from_fd(3)
api_key = read_secret_from_fd(4)

Go:

package main

import (
	"fmt"
	"io"
	"os"
)

func readSecretFromFD(fd int) (string, error) {
	f := os.NewFile(uintptr(fd), fmt.Sprintf("fd-%d", fd))
	if f == nil {
		return "", fmt.Errorf("invalid file descriptor: %d", fd)
	}
	defer f.Close()
	data, err := io.ReadAll(f)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

Secrets live only in process memory.

Key Takeaways

  • Secrets read once via FIFO, data consumed
  • secret-injector exits, no docker exec possible
  • App has no /run/secrets/
  • Prefer file descriptors over env vars
  • No external services needed

— Editorial Team

Advertisement 728x90

Read Next