Using Docker Secrets with NodeJS

Docker Secrets

Docker secrets help to manage sensitive data that a container needs at run-time. For example, usernames, passwords, and certificates. The largest size of a single secret is 500KB.

You can use Docker secrets when your container is running inside a cluster such as Docker Swarm. Docker supports Docker secrets on all types of containers from Docker version 17.06.

Creating Docker secrets

I prefer creating Docker secrets using the command line.

There are two options: use echo or use a file that contains your secret. The following command creates a Docker secret called DB_PASSWORD that holds the string “secretpassword” using echo.

echo "secretpassword" | docker secret create DB_PASSWORD -

The other way is using a text file that contains the value of the secret.

docker secret create DB_PASSWORD db_password.txt

Both of the commands have the same result. A Docker secret DB_PASSWORD is created that contains the secret, the password. As with any Docker resource, you can use the inspect command to get details of the secret.

Docker secret inspect [secretid or name]

If you created the secret successfully, it returns a JSON object with its details. The details do not include value.

[
    {
        "ID": "lefggw7gfeahqz34b70dws8jx",
        "Version": {
            "Index": 15020
        },
        "CreatedAt": "2020-02-02T11:24:00.151051151Z",
        "UpdatedAt": "2020-02-02T11:24:00.151051151Z",
        "Spec": {
            "Name": "DB_PASSWORD",
            "Labels": {}
        }
    }
]

The details of a Docker Secret when executing docker secret inspect

Reading and using Docker secrets in Node.js

Docker uses an in-memory filesystem for storing secrets. Docker secrets look like regular files inside your container.

Docker stores each secret as a single file in /run/secrets/. The filename is the name of the secret. When your Node.js app runs inside a container, it can read a secret like a regular file.

I developed a Node.js module that reads a file from/run/secrets and returns the contents of the file.

// dependencies
const fs = require('fs');
const log = require('../log');

const dockerSecret = {};

dockerSecret.read = function read(secretName) {
  try {
    return fs.readFileSync(`/run/secrets/${secretName}`, 'utf8');
  } catch(err) {
    if (err.code !== 'ENOENT') {
      log.error(`An error occurred while trying to read the secret: ${secretName}. Err: ${err}`);
    } else {
      log.debug(`Could not find the secret, probably not running in swarm mode: ${secretName}. Err: ${err}`);
    }    
    return false;
  }
};

module.exports = dockerSecret;

secrets.js, a Node.js module to read a Docker Secret

For local development, I want to use the .env file as described earlier.

But once the app is running in production, it should read settings from Docker secrets. By combining secrets.js with my standard configuration object, I get the best of both worlds.

/*
 * Create and export configuration variables used by the API
 *
 */
const constants = require('./constants');
const secrets = require('./secrets');

// Container for all environments
const environments = {};

environments.production = {
  httpPort: process.env.HTTP_PORT,
  httpAddress: process.env.HOST,
  envName: 'production',
  log: {
    level: process.env.LOG_LEVEL,
  },
  database: {
    url: secrets.read('STORAGE_HOST') || process.env.STORAGE_HOST,
    name: 'workflow-db',
    connectRetry: 5, // seconds
  },
  workflow: {
    pollingInterval: 10, // Seconds
  },
  authprovider: {
    domain: secrets.read('AUTH_DOMAIN') || process.env.AUTH_DOMAIN,
    secret: secrets.read('AUTH_SECRET') || process.env.AUTH_SECRET
  }
};

// Determine which environment was passed as a command-line argument
const currentEnvironment = typeof process.env.NODE_ENV === 'string' ? process.env.NODE_ENV.toLowerCase() : '';

// Check that the current environment is one of the environment defined above,
// if not default to production
const environmentToExport = typeof environments[currentEnvironment] === 'object' ? environments[currentEnvironment] : environments.production;

// export the module
module.exports = environmentToExport;

Config object that combines Docker Secrets and Environment variables

On row 19, for example, I combine reading a secret and an environment setting secrets.read(‘STORAGE_HOST’) || process.env.STORAGE_HOST. The secret has priority over the environment.

Instead of developing your own, there are existing npm libraries that help with reading Docker secrets.

For example, docker-swarm-secrets, docker-secret, and @cloudreach/docker-secret. These modules read all the Docker secrets and expose them via a JavaScript object.

Assigning Docker secrets to services

We have to give a container explicit permission before it can access a secret. There are two options to give permissions, add it when creating a service or adding it to your docker-compose.yml file.

docker service create --name myservice --secret STORAGE_HOST myimage 

The service myservice has access to the secret STORAGE_HOST which is defined on the host using the command line.

A different and my preferred way is to use a compose file, see below.

The compose file names each secret in a separate block, see row 13. The secrets get the external: true to define that these secrets already exist and were created externally using the command line.

version: "3.5"

services:
  workflow-engine:
    image: pkalkman/mve-workflowengine:0.4.6
    environment:
      AUTH_DOMAIN: /run/secrets/AUTH_DOMAIN
      AUTH_SECRET: /run/secrets/AUTH_SECRET
      STORAGE_HOST: /run/secrets/STORAGE_HOST
      HOST: 0.0.0.0
      HTTP_PORT: 8181
      LOG_LEVEL: 1
secrets:
  AUTH_DOMAIN:
    external: true
  AUTH_SECRET:
    external: true
  STORAGE_HOST:
    external: true

docker-compose.yml that defines and uses Docker Secrets

Docker Secrets in Official Docker Images

If you are wondering how official Docker images handle Docker secrets, there seems to be a typical pattern. Most of the official images that use an environment setting also contain the same environment setting with a _FILE postfix.

For example, the MongoDB image uses the MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables.

It also accepts the MONGO_INITDB_ROOT_USERNAME_FILE and MONGO_INITDB_ROOT_PASSWORD_FILE environment variables. If you set the latter ones to /run/secrets/[secret name], the image will read and use that secret.

So, the official Postgres image also uses the same _FILE pattern.

Using the _FILE Pattern in Node.js

Although the earlier module to read secrets works fine, it would be better to use the same pattern as official images. We only have to make a small change to the secrets module and the config object.

// dependencies
const fs = require('fs');
const log = require('../log');

const dockerSecret = {};

dockerSecret.read = function read(secretNameAndPath) {
  try {
    return fs.readFileSync(`${secretNameAndPath}`, 'utf8');
  } catch(err) {
    if (err.code !== 'ENOENT') {
      log.error(`An error occurred while trying to read the secret: ${secretNameAndPath}. Err: ${err}`);
    } else {
      log.debug(`Could not find the secret, probably not running in swarm mode: ${secretNameAndPath}. Err: ${err}`);
    }    
    return false;
  }
};

module.exports = dockerSecret;

Reading Docker Secrets by specifying the complete path

Instead of accepting the name of the secret and prepending the path of the secret, we receive the complete path. The Config object first reads the value of the _FILE settings, and if not available, it uses the environment setting.

/*
 * Create and export configuration variables used by the API
 *
 */
const constants = require('./constants');
const secrets = require('./secrets');

// Container for all environments
const environments = {};

environments.production = {
  httpPort: process.env.HTTP_PORT,
  httpAddress: process.env.HOST,
  envName: 'production',
  log: {
    level: process.env.LOG_LEVEL,
  },
  database: {
    url: secrets.read('STORAGE_HOST_FILE') || process.env.STORAGE_HOST,
    name: 'workflow-db',
    connectRetry: 5, // seconds
  },
  workflow: {
    pollingInterval: 10, // Seconds
  },
  authprovider: {
    domain: secrets.read('AUTH_DOMAIN_FILE') || process.env.AUTH_DOMAIN,
    secret: secrets.read('AUTH_SECRET_FILE') || process.env.AUTH_SECRET
  }
};

// Determine which environment was passed as a command-line argument
const currentEnvironment = typeof process.env.NODE_ENV === 'string' ? process.env.NODE_ENV.toLowerCase() : '';

// Check that the current environment is one of the environment defined above,
// if not default to production
const environmentToExport = typeof environments[currentEnvironment] === 'object' ? environments[currentEnvironment] : environments.production;

// export the module
module.exports = environmentToExport;

Config object that uses the same pattern as the official Docker images

For example, domain: secrets.read(‘AUTH_DOMAIN_FILE’) || process.env.AUTH_DOMAIN on row 27 first tries to read the secret file, and if it does not succeed, it uses the AUTH_DOMAIN environment setting.

I hope that I have convinced you to start using Docker secrets for storing sensitive information in production.

Thank you for reading and if you have any questions or remarks, feel free to leave a response.

#node-js #docker #javascript #programming

Using Docker Secrets with NodeJS
47.80 GEEK