Docker Secrets
I was recently setting up HabitTrove
and it requires that you set the AUTH_SECRET environment variable to
launch the application. Normally when I have to configure some kind of
runtime secret in a Docker container I’ll use
Docker Secrets.
⚠️ Why use Docker Secrets over simple environment variables?
Environment variables are a liability since they easily leak sensitive information. When you run
docker container inspect <container name/id>it’ll show all environment variables that were set on the container including ones you wouldn’t want other people to see. So anyone that has the ability to access the Docker socket can see them.I also usually see people setting these values directly in their Docker Compose file which means you also have a file laying around on disk that has sensitive information.
Docker Secrets allow you to use some data source to inject these values into the container as a file that only the container can see and resides in memory. Your dockerized application can then just read the file to get the secret and anyone outside of the container won’t be able to read it unless you leak the secret in some other way.
Unfortunately HabitTrove doesn’t natively support Docker Secrets which means my normal way of handling things won’t work. Or will it?
Base setup#
Let’s take a look at the basic Docker Compose file we’ll be working with before we get too far.
---
services:
habit_trove:
image: dohsimpson/habittrove:v0.2.23
restart: unless-stopped
environment:
AUTH_SECRET: my_super_secret_value
volumes:
- type: volume
source: habittrove_data
target: /app/data
- type: volume
source: habittrove_backups
target: /app/backups
ports:
- protocol: tcp
target: 3000
published: 3000
volumes:
habittrove_data:
habittrove_backups:
We have our Docker container get created and expose it’s port 3000. We also mount some volumes to persist it’s data.
We also have an environment variable that contains our authentication secret which we want to get rid of.
Simple enough.
Adding secrets#
Ideally the application image would support secrets natively (and if I can find the time maybe I’ll make a PR for this particular application to add that support) however that isn’t always a possibility. Maybe the image definition isn’t open source or the maintainers just don’t want to add the support but you really want to use the image anyway.
What do we do in that case?
So here’s where it’s going to depend on what else is in the image. In this particular case the Docker image is based on the standard NodeJS Alpine image so it includes the BusyBox version of the Ash shell. So we could include a shell script that can help us out.
This shouldn’t be too hard to do but let’s write out the steps that we’ll need to accomplish.
- Within the container check if there are secrets that have been configured
- Attempt to read the secret file to get the secret value
- Export the secret value with the environment variable name that the application expects
- Launch the main application
First pass at script#
ℹ️ While we’re working on our shell script it is super important to remember that in this particular case we’re using Alpine Linux as our base and it does NOT include the Bash shell, it includes the Ash shell and the two have some syntactic differences that we need to account for. In my opinion Bash is much easier to work with but we have to roll with what we got.
Here’s our first attempt at a shell script to handle the steps we outlined above:
#!/bin/sh
# These flags are super important to prevent yourself from making
# silly mistakes.
#
# `errexit` makes sure the shell exits immediately
# when there's an error (not the default).
#
# `nounset` will raise an exception if you attempt to use a variable
# that isn't set yet (not the default).
#
# `pipefail` will raise an exception if you are using the pipe
# operator and an error occurs at the front/middle of the pipeline
# (not the default). We don't use a pipe in this script but I always
# set it just in case I end up using it later.
set -o errexit -o nounset -o pipefail
function load_secret() {
local name="${1}"; shift
# By convention, Docker secrets end with `_FILE`
secret_file_var_name="${name}_FILE"
secret_path="$(eval "echo \$${secret_file_var_name}")"
if [ -z "${secret_path:-}" ]; then
continue
fi
secret="$(cat "${secret_path}")"
export "${name}"="${secret}"
}
function main() {
load_secret AUTH_SECRET
# TODO: load any other secrets you might have here
/usr/local/bin/node server.js
}
main "${@}"
A quick note on one line that is really worth mentioning.
secret="$(cat "${secret_path}")"
ℹ️ We’re just blindly trying to read the file that contains the secret without checking if the file exists or if we have permission to do so. This was a deliberate choice on my part to “ask for forgiveness instead of permission” but you could absolutely do those additional checks. Just keep in mind that things could change from when you did the checks and when you actually read the file. To me, the biggest benefit to doing those checks is you can provide a nicer error message.
Now we need to update the Compose file to make use of this script and our secret.
---
services:
habit_trove:
image: dohsimpson/habittrove:v0.2.23
restart: unless-stopped
command: /docker-entrypoint.sh
secrets:
- habittrove_auth_secret
environment:
# This file is mounted by the above secrets section.
AUTH_SECRET_FILE: /run/secrets/habittrove_auth_secret
volumes:
- type: bind
source: ./docker-entrypoint.sh
target: /docker-entrypoint.sh
read_only: true
- type: volume
source: habittrove_data
target: /app/data
- type: volume
source: habittrove_backups
target: /app/backups
ports:
- protocol: tcp
target: 3000
published: 3000
secrets:
habittrove_auth_secret:
# I'm using the file method for loading secrets here but you could
# provide the secret by other means as well if you prefer. This
# was just the easiest for demonstration purposes.
file: ./habittrove_auth_secret.txt
volumes:
habittrove_data:
habittrove_backups:
We’re just mounting the script into the image using a bind mount and changed the command that is used when the container is started to be our script.
⚠️ In some cases you might need to also set the entrypoint for the container to a shell. I got lucky here and didn’t need to worry about that.
Now we can run the container and profit right?
Permissions#

File permissions issues
It looks like the container wasn’t able to start in my case (for you
it might work fine). Let’s look at what ls has to say about things…
drwxr-xr-x 2 james james 4096 Jul 14 09:18 .
drwxr-xr-x 3 james james 4096 Jul 11 12:16 ..
-rw-r--r-- 1 james james 937 Jul 14 09:18 docker-compose.yml
-rwxr-xr-x 1 james james 465 Jul 14 09:18 docker-entrypoint.sh
-rw------- 1 root root 45 Jul 14 09:18 habittrove_auth_secret.txt
Ah there we go, my credentials file is owned by the root user and
only the root user is allowed to read or modify it. When I run
services I try to have a user that has specific access to the credentials
so that someone else on the system couldn’t just access them whenever
they want (I wouldn’t recommend the root user though…). So what
do we do now?
We could accept less security and weaken the file permissions but that’s
not necessary. We can instead just change the user that we use to start
the container. In our case, the container starts as the nextjs user
that was created when the image was built (user ID 1001). We could try
and make sure that we have a matching user on our host system but that
could get messy.
Normally I would say to just create a new user on the host with a matching
ID in the container but we’re going to go an easier route for this
example and just use the root user to start the container and then
switch to the nextjs user before starting the application.
First let’s adjust our Compose file again:
---
services:
habit_trove:
image: dohsimpson/habittrove:v0.2.23
restart: unless-stopped
# Not a best practice but it conveys the point!
user: root
command: /docker-entrypoint.sh
secrets:
- habittrove_auth_secret
environment:
AUTH_SECRET_FILE: /run/secrets/habittrove_auth_secret
volumes:
- type: bind
source: ./docker-entrypoint.sh
target: /docker-entrypoint.sh
read_only: true
- type: volume
source: habittrove_data
target: /app/data
- type: volume
source: habittrove_backups
target: /app/backups
ports:
- protocol: tcp
target: 3000
published: 3000
secrets:
habittrove_auth_secret:
file: ./habittrove_auth_secret.txt
volumes:
habittrove_data:
habittrove_backups:
We’ve just added a user: root bit to the container defintion to
start as the root user. Pretty straightforward.
Now we need to adjust our script so that it will switch to the correct user after loading credentials.
#!/bin/sh
set -o errexit -o nounset -o pipefail
function load_secret() {
local name="${1}"; shift
secret_file_var_name="${name}_FILE"
secret_path="$(eval "echo \$${secret_file_var_name}")"
if [ -z "${secret_path:-}" ]; then
continue
fi
secret="$(cat "${secret_path}")"
export "${name}"="${secret}"
}
function main() {
load_secret AUTH_SECRET
# We switch to the `nextjs` user and run the shell to start the
# actual application.
su -s /bin/sh nextjs -c '/usr/local/bin/node server.js'
}
main "${@}"
Everything is the exact same except for the last line of the main
function.
su -s /bin/sh nextjs -c '/usr/local/bin/node server.js'
We’re switching to the nextjs user and specifying which shell should
be used after switching. We then tell it to launch the node binary
with our script. In my specific case, the nextjs user was configured
in the Docker image to have the nologin shell which (as the name
implies) prevents the user from successfully logging in. This doesn’t
help us. We override that to use the Ash shell instead and then
immediately switch to the actual application.
Now the application properly starts and has the secret loaded as we wanted.