secrets¶
experimental
this is experimental, and it's how i do it, not what i'd suggest for most people. if you just want secrets out of your compose files, use docker secrets, or set environment variables by hand in portainer.
how a secret reaches a container, and which of the five ways to pick.
for years i typed passwords straight into the stack editor in portainer as environment variables. it works, but it is the worst of the options.
environment variables in the service spec¶
an environment variable on a swarm service is stored in the service spec. anyone who can talk to the docker api can read it back in plain text, without access to the container or the host filesystem.
you can read your own back:
i had a dockerproxy container exposing the docker socket over tcp on my lan
with no auth, so every password in every stack was readable from any device in
the house with one curl. it's read-only and the port is closed now, but the fix
was getting the values out of the specs.
what docker gives you¶
docker only ever delivers a secret as a file, mounted at /run/secrets/<name>
on a tmpfs. the tmpfs is in ram, read only, and gone when the task stops. docker
never sets an environment variable for you.
so the job is getting from "there is a file" to "the app's config variable is set". there are four ways to do that, plus labels, where only the app reading them can help.
create a secret¶
docker secret create reads the value from a file, which keeps it off the
command line:
that leaves nothing sensitive in your history or the process list, and you can shred the file after.
if you do type it inline, note the leading space below. it keeps the line
out of history only if your shell is set to skip such lines:
HISTCONTROL=ignorespace in bash or setopt HIST_IGNORE_SPACE in zsh. neither
is on by default everywhere:
then the stack needs both halves:
services:
db:
secrets: # grant: mount this file into this service
- my_secret
secrets:
my_secret:
external: true # already exists in the swarm, don't create it
without the service-level secrets: list, the file isn't there.
secrets are immutable. to change a value you create a new secret under a new
name and repoint the compose, which is why my secret names have version
suffixes. a new one starts at _v1, and each change moves it on to _v2 and
then _v3.
option 1, the app reads the file itself¶
this is the best case: there is nothing to do but grant it.
adguardhome-sync knows nothing of docker secrets, but it takes a config file,
so the secret is the config file:
both adguard passwords live in that yaml and neither ever appears in the spec.
option 2, the _FILE convention¶
lots of official images support this. instead of FOO you set FOO_FILE to a
path and the image's own entrypoint reads it. wordpress and mysql both do:
environment:
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/wordpress_db_password
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/wordpress_mysql_root_password
check the image's docs first. it's common but not universal, and some images support it for one variable and not another.
option 3, an entrypoint wrapper¶
use this when the image supports neither, or when its own file support fails the
wrong way. npm, below, reads DB_MYSQL_PASSWORD__FILE, but on a missing file it
logs it and starts anyway, with no password. the wrapper stops the container
instead. it reads the file, exports the variable, then uses exec to run
whatever the image normally runs:
entrypoint:
- /bin/sh
- -c
- >
set -e;
DB_MYSQL_PASSWORD="$$(cat /run/secrets/npm_db_password)";
export DB_MYSQL_PASSWORD;
exec /init
four things to get right:
- do not write
export VAR="$(cat ...)". it looks equivalent and is the form you will find in most examples.exportis a command, and its own exit status is 0, soset -enever sees thecatfail and your app starts with an empty password. a plain assignment propagates the failure, so a missing secret crashes the container instead. try it:
sh -c 'set -e; V="$(cat /nope)"; export V; echo REACHED' # exit 1, silent
sh -c 'set -e; export V="$(cat /nope)"; echo REACHED' # exit 0, REACHED
this matters most where an empty value doesn't make the app fail: an app that falls back to a different auth mode, or an image with a default baked in
-
$$is compose's escape for a literal$. write one$and compose substitutes it at deploy time, which is what you're trying to avoid -
execthe command. withoutexecyour shell stays as pid 1 and signal handling breaks, so the container stops responding todocker stop -
the image needs a shell. distroless images have no
/bin/sh, so this option isn't available for them. check first:
you also need the image's real entrypoint and command to put back.
docker inspect shows them.
option 4, environment variables¶
this is what i did for years, and it is sometimes still the only option. one of
my stacks, oauth2-proxy, still uses it: its image has no shell, and its client
id has no _FILE form. the way out is its config file mounted as a secret, like
adguardhome-sync in option 1. if you have to use this one, know the value is
readable over the api.
labels¶
two of my adguard passwords were homepage.widget.password labels. a label is
part of the service spec, so it cannot be a secret. it cannot use _FILE
either, and an entrypoint wrapper cannot set it, because the scheduler applies
it before the container exists.
a label can hold a placeholder instead. homepage reads those labels and swaps
{{HOMEPAGE_FILE_X}} for the contents of the file named by its
HOMEPAGE_FILE_X variable. the label only says:
and the password sits in a file on homepage's config volume, never in a spec. this works only because homepage does the swap. docker has no mechanism for it, so a label that nothing swaps still can't carry a secret.
non-swarm hosts¶
docker secret is swarm only. on a standalone host the api says:
but the compose syntax still works with file: instead of external: true:
compose bind mounts that file to /run/secrets/my_secret, so options 1, 2 and 3
work unchanged. only the storage differs: it's a plain file on that host's disk,
with no encryption and no replication. this works for linux containers only.
if the compose comes from git, use an absolute host path. a relative
file: ./secret.txt resolves relative to the compose file, which under gitops
means inside the cloned repo, and you would be committing the value.
where they live¶
swarm secrets are stored in the raft log on the manager nodes:
the log is encrypted and replicated to every manager. two things to understand:
you cannot read a secret back out. the api is write only for values. if you haven't kept a copy somewhere, the only place that value exists is inside the containers that have it mounted.
the encryption depends on autolock. with AutoLockManagers false, the default
and what i run, the decryption key sits on the same disk, so managers can reboot
unattended. that protects against the docker api and the service specs, not
against someone with root on a manager or a copy of the vm. autolock closes that
gap, at the price of unlocking the swarm by hand after every reboot.
so keep your own copy of every value outside the swarm. mine is
secrets.enc.yaml: every value is encrypted with sops and age, the file is
committed to a private repo, and azure key vault holds a copy. the key-manager
pages below show how.
pick one, in this order¶
- the app reads the file. nothing in the spec, nothing to maintain
_FILE. nothing in the spec, one line of config- entrypoint wrapper. nothing in the spec, but you own the entrypoint now
- environment variable. readable over the api. last resort
- label. never the value: a placeholder, if the app reading the label fills one from a file. otherwise change the design
read your own service specs back even if you fix nothing today. i assumed none of mine were readable over the api for about four years.
the key-manager tooling¶
the operations have their own pages:
| page | for |
|---|---|
| background | the model, in depth. age and SOPS, and where Key Vault fits |
| key-manager reference | how to run the container, what it mounts, and every command |
| add a secret | a value that does not exist yet |
| rotate a secret | replacing one that does, and why rotation difficulty is set by where the authority lives |
| retire a secret | taking a superseded one out of service |
| recover | reading the store from anywhere, restoring it, rebuilding a swarm |
the tooling is a public container, ghcr.io/scyto/key-manager, so the commands
on those pages run as written. they operate on my own encrypted store, so
substitute your own.
one-time setup: the keyman alias¶
every command on the operations pages runs inside that container, never on a
host. the container runs with --rm, so the clone, the logins and any key go
when you exit.
1 add this to ~/.zshrc, as one line:
alias keyman='ssh -t docker01 '\''docker pull -q ghcr.io/scyto/key-manager:latest && docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock --dns-search mydomain.com -e AKV_VAULT=YourKeyVaultName -e AKV_TENANT=00000000-0000-0000-0000-000000000000 -e GIT_AUTHOR_NAME=you -e GIT_AUTHOR_EMAIL=you@mydomain.com -e HOMELAB_REPO_URL=https://github.com/you/your-repo.git ghcr.io/scyto/key-manager:latest'\'''
2 load it, and check it reaches a prompt. type exit to leave:
- it goes through
ssh -t <a swarm manager>becauseprovisionandverifyneed that node's docker socket, which on a manager is the Swarm API.-tgives it a terminal. without itdocker run -itfails with "the input device is not a TTY" - paste it as one line. splitting the
sshand thedockerparts across lines runs the docker half on your workstation after ssh exits HOMELAB_REPO_URLis the one you must change. the container clones a repo into/repoon first run, and left unset that is my repo, which is private, so the clone fails for anyone else. point it at your own.clone --url <repo>does the same thing per-run- substitute your own vault, tenant and domain
- there are other ways to run it, including on a machine with nothing set up, see key-manager reference