Using Docker Secrets with NodeJS

This post shows how to use (and read from) Docker Secrets within a NodeJS app.

Why use it?

The difference when using (Docker) containers is that things which used to be transient (thus dying with the process) do become persisted (e.g. in image history, build cache). For instance, even when using environment variables, a simple docker inspect <... > or docker image history <...> can reveal sensitive data which survived the process (container) even after its exit.

Prerequisites

  • Docker Engine v1.13 or newer
  • Docker Swarm (as of now because secrets are stored in the encrypted Swarm Raft log)
  • If using Docker-Compose: Docker-Compose File v3.1 or newer

Use Cases

Secrets, once created via e.g. docker cli, are replicated among Swarm hosts. This gives you a replicated (thus host-independent), read-only tmpfs volume where each secret/string can be up to 500 kb in size.

If granted access to the secret and no matter on which Swarm host the granted container is running, Docker Secrets are mounted into the container in a tmpfs filesystem at /run/secrets/.

1. PKI Files/ Certificates

Create Secret

Creating a secret from a file via docker cli is simple:

docker secret create \
  --label environment=development \
  --label type=cert \
  app_cert_key app_key.pem

This creates a secret called app_cert_key from the file app_key.pem in the current directory. The specified labels are optional but may be used to later filter certain secrets.

Grant access

If using docker-compose version 3.1 is necessary:

version: "3.1"

services:
  <service_name>:
    <...>
    secrets:
      - app_cert_key
    <...>

secrets:
  app_cert_key:
   external: true

Read Secret docker cli

While the container is running, you can:

docker exec <container_id> cat /run/secrets/app_cert_key

Read Secret nodejs

From the NodeJS application you need to use the fs module:

const fs = require('fs');

fs.readFileSync('/run/secrets/app_cert_key', 'utf8')
# or
fs.readFile('/run/secrets/app_cert_key', 'utf8')
# or
fs.readFile('/run/secrets/app_cert_key', 'utf8').trim()

While .trim() is not mandatory for a certificate (it is read correctly even without trimming) it is important to use for other strings as a Line Feed is at the end of the read string.

2. Credentials

Create Secret

Although not advisable (as preserved in your shell history) secrets can be piped with an echo

echo "This is a secret" | docker secret create my_secret_data -

However, the first pipe may also be something less verbose such as e.g. mac os's keychain:

security find-generic-password -a <account> -w /path/to/keychain | docker secret create my_secret_data -

Grant access

Via docker-compose v3.1 you might consider using an environment variable for the name (not the content) of the secret:

export MY_SECRET_DATA_ENV_VAR="my_secret_data"
version: "3.1"

services:
  <service_name>:
    <...>
    secrets:
      - MY_SECRET_DATA
    environment:
      - MY_SECRET_DATA_ENV_VAR=${MY_SECRET_DATA_ENV_VAR}
    <...>

secrets:
  MY_SECRET_DATA:
    external:
      name: ${MY_SECRET_DATA_ENV_VAR}

Depending on your overall environment(s) this offers the opportunity to switch the name (and thus the content) of your secret in each environment.

Read Secret docker cli

Again, while the container is running, you can:

docker exec <container_id> cat /run/secrets/my_secret_data

You can as well do a docker secret inspect my_secret_data to check the metadata (especially added labels).

Read Secret nodejs

Again, in the NodeJS application you need to use the fs module and can also access the MY_SECRET_DATA_ENV_VAR environment variable:

const fs = require('fs');

fs.readFileSync('/run/secrets/' + process.env.MY_SECRET_DATA_ENV_VAR, 'utf8').trim()

Now .trim() is mandatory to use as a line feed is at the end of the string. You can check e.g. via:

console.log(fs.readFileSync('/run/secrets/' + process.env.MY_SECRET_DATA_ENV_VAR));
console.log(fs.readFileSync('/run/secrets/' + process.env.MY_SECRET_DATA_ENV_VAR, 'utf8'));
console.log(fs.readFileSync('/run/secrets/' + process.env.MY_SECRET_DATA_ENV_VAR, 'utf8') + "test");
console.log(fs.readFileSync('/run/secrets/' + process.env.MY_SECRET_DATA_ENV_VAR, 'utf8').trim() + "test2");

# Outputs
<Buffer 54 68 69 73 20 69 73 20 61 20 73 65 63 72 65 74 0a>
This is a secret

This is a secret
test
This is a secrettest2

The 0a at the end of the Buffer represents a line feed. This is also shown by the next two console.log outputs. The last output shows that by .trim() the line feed has been removed.

If worried about using .trim() (as e.g. you might want to preserve something that .trim() removes) you might want to consider .toString().slice(0, -1)) which only removes the last character.

3. Configuration files

As indicated by Docker secrets can also be used for

  • ...
  • other important data such as the name of a database or internal server
  • Generic strings or binary content (up to 500 kb in size)

Remembering the fact that docker secrets provide you with a fully replicated, read-only tmpfs filesystem, this opens up possibilities to share (small) state or configuration between containers across hosts.

Update: Meanwhile there are Docker Configs available which work pretty much like Docker Secrets.

Limitations

Although docker secrets do remove the aspect of persisting sensitive data mentioned in the beginning, two things need to be kept in mind:

  1. Now (all) Docker hosts do persist the secrets. Well encrypted in the Swarm Raft log, but they are now there.
  2. During container runtime everybody with access to docker exec <container_id> can retrieve the data.

The second point even remains when using the long syntax for advanced file permissions and the nodejs container running as user node (as configured in the official image)

version: "3.1"

services:
  <service_name>:
    <...>
    secrets:
      - source: app_cert_key
        target: app_cert_key_name_in_container
        uid: "1000"
        gid: "1000"
        mode: 0400
    <...>

secrets:
  app_cert_key:
   external: true

The following commands still work:

docker exec <container_id> cat /run/secrets/app_cert_key_name_in_container`  
docker exec -u root <container_id> cat /run/secrets/app_cert_key_name_in_container

The following command does not work (Permission denied):

docker exec -u <other_unpriviledged_user> <container_id> cat /run/secrets/app_cert_key_name_in_container`

Conclusion

As these limitations do not differ very much from using Docker without secrets, docker secrets are worth using as they at least remove sensitive data from your images. Especially in conjunction with environment variables they offer flexibility for passing varying (and not only sensitive) data to containers.

Further information

Docker Docs & Blog:
https://docs.docker.com/engine/swarm/secrets/#simple-example-get-started-with-secrets
https://docs.docker.com/compose/compose-file/#secrets
https://blog.docker.com/2017/02/docker-secrets-management/

Docker Configs:
https://docs.docker.com/engine/swarm/configs/
https://docs.docker.com/engine/reference/commandline/config/

Other tutorials:
http://blog.alexellis.io/swarm-secrets-in-action/
https://dzone.com/articles/docker-security-using-docker-secrets-with-swarm

Utilities used:
https://nodejs.org/api/fs.html
https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/String/Trim