Dockflow

Secrets and configs

Hand secrets to containers as files that never appear in docker inspect, mount configuration files, and pass secrets to builds.

Dockflow reads secrets from your CI variables, .env.dockflow or DOCKFLOW_SECRETS (see configuration). A container gets a secret in one of two ways:

  • Interpolated, as ${DB_PASSWORD} in environment:. Most images expect this. The value becomes part of the container's configuration: anyone with Docker access on the server sees it with docker inspect.
  • As a file, with Compose secrets:. The value is written to a file on a tmpfs of the server, and mounted read-only in the container. It appears neither in docker inspect, nor in the container's environment, nor in the release records. Use this for anything sensitive.

A secret as a file

compose.yml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
secrets:
  db_password:
    environment: DB_PASSWORD   # the secret DB_PASSWORD (PRODUCTION_DB_PASSWORD in CI)

The official images of Postgres, MySQL, MariaDB and many others read a *_FILE variable for each setting that holds a password.

A secret's source is one of:

  • environment: <KEY>: a secret or variable Dockflow knows;
  • file: <path>: a file of your project, read on your machine.

The long syntax of a service's secrets: sets where the file goes and who can read it:

# compose.yml (fragment)
services:
  app:
    image: ghcr.io/acme/app:1.4
    user: "1000"
    secrets:
      - source: api_token
        target: /etc/app/token     # default /run/secrets/<source>; a relative target goes under /run/secrets
        uid: "1000"
        gid: "1000"
        mode: 0400                 # default 0444

Configs

Compose configs: mount configuration files the same way, from file:, content: or environment:. They are not secret: Dockflow keeps them on the server's disk, with the app's state.

# compose.yml (fragment)
services:
  proxy:
    image: nginx:1.27
    configs:
      - source: site
        target: /etc/nginx/conf.d/default.conf
configs:
  site:
    content: |
      server {
        listen 80;
        location / { proxy_pass http://app:3000; }
      }

content: is interpolated like the rest of the Compose file, so ${DOMAIN} works in it. For a real template, write the file and list it under templates:.

How a new value reaches the containers

Each secret and config file is named after the hash of its content, owner and mode. So a new value is a new file:

  1. The plan shows secret.create (or config.create) for the new file, and service.update for the services that mount it.
  2. The services move to the new file with their usual rollout, without downtime for routed services.
  3. After the deploy, files that no kept release uses are deleted. A rollback to a kept release finds its files on the server.

An unchanged value changes nothing: the next deploy's plan is empty.

After a reboot

The tmpfs is emptied when the server reboots. Dockflow keeps a copy of each secret file in its state directory, /var/lib/dockflow, readable by root only. The values interpolated into environment: are already kept there too.

After a reboot, the watchdog writes the files of the current releases again from these copies. It then starts the containers Docker could not start without them. A rollback finds its files the same way.

Without the watchdog (server init --no-watchdog), the apps that use Compose secrets wait for the next deploy.

Dockflow writes secrets under /run/dockflow, which must be on a tmpfs. On a systemd distribution, /run is one. Elsewhere, a deploy that needs a secret file fails with secret.not_tmpfs and writes nothing: mount a tmpfs on /run/dockflow first.

Secrets in builds

A build gets a secret with build.secrets, as in Compose. The Dockerfile mounts it in the one RUN instruction that needs it, and it does not stay in the image:

# compose.yml (fragment)
services:
  web:
    build:
      context: .
      secrets: [npm_token]
secrets:
  npm_token:
    environment: NPM_TOKEN
# Dockerfile (fragment)
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

Dockflow hands the value to buildx (or Podman) through the build process's environment, never on a command line.

BuildKit does not take a secret's value into account for its cache. If a step must run again when the secret changes, use no_cache or change the step.

Secrets in a SOPS file

Instead of CI variables, an environment's secrets can live in git, in a SOPS file encrypted with age: secrets/production.sops.yaml for production.

Dockflow decrypts the file in memory. It reads it after CI variables and .env.dockflow, which win over it. The file is a regular sops file: either tool reads and writes it.

  1. Create an age key, and keep the private part where sops looks for it:

    age-keygen -o ~/.config/sops/age/keys.txt

    Dockflow tries the keys of SOPS_AGE_KEY, and those of the file SOPS_AGE_KEY_FILE names. When that variable is not set, it reads $XDG_CONFIG_HOME/sops/age/keys.txt, which defaults to ~/.config/sops/age/keys.txt (~/Library/Application Support/sops/age/keys.txt on macOS).

  2. Name the public key, age1…, as the recipient of your SOPS files:

    .sops.yaml
    creation_rules:
      - path_regex: secrets/.*\.sops\.yaml$
        age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
  3. Set secrets. The value is read from stdin, or asked for without echo:

    dockflow secrets set production DB_PASSWORD
    dockflow secrets list production
  4. In CI, give the private key as the repository's SOPS_AGE_KEY secret, instead of one secret per value:

    # .github/workflows/deploy.yml (fragment)
          - run: dockflow deploy
            env:
              PRODUCTION_CONNECTION: ${{ secrets.PRODUCTION_CONNECTION }}
              SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
  • dockflow secrets edit production opens every value in your editor.
  • A file is a flat map of KEY: value. Keys ending with _unencrypted stay in clear, as with sops.
  • To give each person and the CI their own key, add several recipients.

Revoking a leaked secret

After a new value is deployed, the files of the old one stay on the server, so a rollback still works. When the old value leaked, remove them at once:

dockflow secrets revoke production db_password

Their copies are removed too, so the releases that used the old value can no longer be rolled back to.

Masking

The values of secrets and of secret files are masked in everything Dockflow shows or stores: events, build and hook output, errors. Interactive commands (exec, run, ssh) show the command's own output, unmasked.

Edit on GitHub

On this page