Most of the apps, especially those dealing with external world need some configuration in order to work properly. As it may run in different environments it is not possible to provide those at compile time to ensure type safety, but we may use libraries like typematcher there.

In this post I’ll go through the process of configuring a nodejs application in a type-safe manner and running it as docker swarm service.

App and Configuration

As a demo app for this example I’m going to use this seed repo I personally use for my new projects, written in TypeScript.

The configuration is loaded by the config package and the typematcher is matching config against the pre-defined structure and types.

Let’s assume we have a component which requires some configuration options and we have defined them as:

export type UsersServiceConfig = {
  title: string,
  enabled: boolean,
  retries: number
};

Our app will probably need more of such configurations so we can compose them into single app config type:

import {UsersServiceConfig} from "./routes/users";

// this type will contain configs for all our internal components/services
type ServicesConfig = {
  users: UsersServiceConfig
};

// this is the root node of application config
type Config = {
  services: ServicesConfig
};

Next we will have to define our validation rules for the config types:

import {
  match, isString, isNumber, isBoolean, caseId, hasFields,
  caseThrow, failWith
} from 'typematcher';

// this will check a value to conform to UsersServiceConfig structure
const isUserServiceConfig = hasFields({
  title: failWith(
    new Error('Invalid services.users.title configuration option: string expected')
  )(isString),
  enabled: failWith(
    new Error('Invalid services.users.enabled configuration option: boolean expected')
  )(isBoolean),
  retries: failWith(
    new Error('Invalid services.users.retries configuration option: number expected')
  )(isNumber)
});

const isServicesConfig = hasFields({
  users: failWith('Invalid services.users config')(isUserServiceConfig)
});

And finally load the config and export it to its users:

import * as config from 'config';

// Unfortunately there is no way yet to get full config using config package, ex: `config.get('.')` or `config.get()`
// So will build final config from parts, getting one by one
const conf: Config = {
  services: match(config.get("services"))(
    caseId(isServicesConfig),
    caseThrow(new Error('One or more services configs are invalid'))
  )
};

export default conf;

If the loaded config will fail to match the defined requirements it will throw an exception.

Source file: https://github.com/lostintime/express-ts-seed/blob/master/src/config.ts

But where is the config itself?

Theconfig package is using environment variables and file load order rules which define the config loading behavior.

By default configs files located in ./config folder relative to project root. This may be overwritten by NODE_CONFIG_DIR environment variable.

File load order rules are defined using NODE_ENV, HOSTNAME and NODE_APP_INSTANCE environment variables which we may use to configure the app with docker secrets.

Running the application in a swarm cluster

To init swarm mode on your docker installation run docker swarm init. Single node swarm cluster is enough for this example.

Application image

This app is packed to docker image using this script and published to docker hub: lostintime/express-ts-seed

Preparing configurations

I usually keep default configuration file in the config/ directory (config/default.json) and commit it to git (it does not contain any secret info, only defines the structure), and there is 1 more file per environment, ex: development.json, production.json, in same folder, ignored with .gitignore and .dockerignore, but any other folder which works for you - is just perfect.

DO NOT commit configs files to git and DO NOT bundle with docker images, this is bad, bad practice!

For this demo I’ll name service as express-ts-seed (same may be used as HOSTNAME) and following config’s file load order rules will prepare 2 config files:

File: express-ts-seed.json

{
  "services": {
    "users": {
      "title": "Users Service",
      "enabled": false,
      "retries": 3,
      "other": 10
    }
  }
}

File: express-ts-seed-production.json

{
  "services": {
    "users": {
      "title": "Users Service, this is overwritten in {deployment} config files",
      "anotherOne": "This is also added in {deployment} file",
      "filename": "express-ts-seed-production.json"
    }
  }
}

Creating docker secrets

$ docker secret create express-ts-seed.json express-ts-seed.json

p7ebq3gfgjyeg8rc4rvrvf6uw
$ docker secret create express-ts-seed-production.json express-ts-seed-production.json

9r4n47k4pn8eukyhqv793f7d3

Let’s check the available secrets:

$ docker secret ls

ID                          NAME                              CREATED             UPDATED
9r4n47k4pn8eukyhqv793f7d3   express-ts-seed-production.json   26 seconds ago      26 seconds ago
p7ebq3gfgjyeg8rc4rvrvf6uw   express-ts-seed.json              53 seconds ago      53 seconds ago

The filenames (and the secret names) are selected in this way in order to avoid conflicts with the other services running in the same swarm cluster and also using docker secrets.

Creating the docker service

Now we are ready to run our app:

$ docker service create \
    --name "express-ts-seed" \
    --hostname "express-ts-seed" \
    --env "NODE_ENV=production" \
    --env "NODE_CONFIG_DIR=/run/secrets" \
    --secret "source=express-ts-seed.json,target=default.json" \
    --secret "source=express-ts-seed-production.json,target=production.json" \
    --endpoint-mode "vip" \
    --mode "replicated" \
    --replicas 3 \
    --update-parallelism 1 \
    --update-delay 1s \
    --stop-grace-period 5s \
    --restart-condition "any" \
    --restart-delay 10s \
    --restart-max-attempts 1 \
    --publish "3000:3000" \
    --detach=false \
    lostintime/express-ts-seed:0.2.1

pxxsnvbhmx45cuily0cylgic6
overall progress: 3 out of 3 tasks 
1/3: running   [==================================================>] 
2/3: running   [==================================================>] 
3/3: running   [==================================================>] 
verify: Service converged 

And it converged! Whatever it means :). http://localhost:3000/.

By using custom target name for --secret argument - our secrets was mounted at /run/secrets/default.json and /run/secrets/prduction.json.

By setting NODE_CONFIG_DIR=/run/secrets and NODE_ENV=production environment variables - we have configured the config package to use /run/secrets for searching config files and 2 of file load order rules matched:

...
default.EXT
...
{deployment}.EXT
...

This app exposes an http endpoint which returns a part of it’s config, which you will hopefully NEVER do in your production app: http://localhost:3000/users/config.

There you can see the contents of express-ts-seed-production.json and express-ts-seed.jsonsecrets merged together. You could just actually use a single file containing the full configuration, but that’s up to you.

Changing the configuration

What about later configuration changes? Well, you cannot just update or re-create secrets because:

  • docker secrets cannot be updated (which is actually a good thing and perfectly fits immutable infrastructure goals)
  • docker secrets cannot be removed while the service is using it, which again is not that bad

So - how we’re going to update our app configuration then?

Creating new secrets

Every secret must have different (unique) name, but fortunately we can set custom target secret name so we are going to mount different secrets at same paths:

$ docker secret create express-ts-seed-production-0.json express-ts-seed-production.json
ys88p2d8o3biicivm58ksx3wx
$ docker secret ls
ID                          NAME                                CREATED             UPDATED
9r4n47k4pn8eukyhqv793f7d3   express-ts-seed-production.json     27 minutes ago      27 minutes ago
p7ebq3gfgjyeg8rc4rvrvf6uw   express-ts-seed.json                28 minutes ago      28 minutes ago
ys88p2d8o3biicivm58ksx3wx   express-ts-seed-production-0.json   15 seconds ago      15 seconds ago

Updating the service

Updating env will also restart the service nodes, and if new the config is not compatible with the old app you may have to update the service image within same command.

$ docker service update \
    --secret-rm "express-ts-seed-production.json" \
    --secret-add "source=express-ts-seed-production-0.json,target=production.json" \
    --detach=false \
    express-ts-seed
    
express-ts-seed
overall progress: 3 out of 3 tasks 
1/3: running   [==================================================>] 
2/3: running   [==================================================>] 
3/3: running   [==================================================>] 
verify: Service converged     

Removing old secrets

$ docker secret rm express-ts-seed-production.json
 
express-ts-seed-production.json

Using compose file

The deployment we created above can be described in a compose file and deployed as a docker stack.

Compose file (./docker-compose.yml):

version: '3.4'

services:
  express-ts-seed:
    image: lostintime/express-ts-seed:0.2.1
    hostname: "express-ts-seed"
    ports:
      - "3000:3000"
    secrets:
      - source: default_v1.json
        target: default.json
      - source: production_v1.json
        target: production.json
    environment:
      - "NODE_ENV=production"
      - "NODE_CONFIG_DIR=/run/secrets"
    deploy:
      mode: replicated
      replicas: 3
      update_config:
        parallelism: 1
        delay: 1s
      restart_policy:
        condition: any
        delay: 10s
        max_attempts: 1
        window: 3m
    stop_grace_period: 5s

networks:
  internal:
    driver: overlay

secrets:
  default_v1.json:
    file: ./express-ts-seed.json
  production_v1.json:
    file: ./express-ts-seed-production.json

Deploy:

$ docker stack deploy --compose-file "./docker-compose.yml" --prune express_ts_seed

When you change configuration files - change secrets name in docker-compose.yml file, ex: default_v1.json to default_v2.json, change secret references in service secrets section and re-deploy the stack.

$ docker stack deploy --compose-file "./docker-compose.yml" --prune express_ts_seed

Then you can remove old secret:

$ docker secret rm express_ts_seed_default_v1.json

Conclusion

Using typematcher and config npm packages we may configure our applications in a type-safe manner, and connect configurations using secrets while deploying apps to docker swarm

Same effect may be achieved with kubernets, since the k8s secrets are mounted as volumes and you also get to choose the config files location.

As a less secure but more flexible option - docker configs may be used in similar fashion:

Configs operate in a similar way to secrets, except that they are not encrypted at rest and are mounted directly into the container’s filesystem without the use of RAM disks. Configs can be added or removed from a service at any time, and services can share a config. You can even use configs in conjunction with environment variables or labels, for maximum flexibility.

Some issues

Some issues would be nice to solve:

  • There is no way to get full config, ex. using config.get('.') or config.get();
  • There is no way to specify a custom config file directly, ex: NODE_CONFIG_FILE=/path/to/file, but NODE_CONFIG_DIR worked.