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 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 a seed repo I personally use for new projects, written in TypeScript.

Configuration is loaded using config package and typematcher is matching config for pre-defined structure and types.

Let’s assume we have a component which require some config options and define 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 define validations for 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 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 loaded config will fail to match 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?

config package is using environment variables and file load order rules which defines 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 that will help us do the trick with using docker secrets to configure the app.

Running Application on 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

Prepare configurations

I usually keep default configuration file in config/ folder (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, and same will be used as HOSTNAME, so, 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"
    }
  }
}

Create 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 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

Filenames (and secret names) selected to avoid conflicts with other services running on same swarm cluster and also using docker secrets.

Create docker service

Now we’re 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 "express-ts-seed.json" \
    --secret "express-ts-seed-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 setting NODE_CONFIG_DIR=/run/secrets environment variable - we configured config package to use /run/secrets for searching config files, and using --hostname "express-ts-seed" and NODE_ENV=production 2 of file load order rules matched:

...
{full_hostname}.EXT
...
{full_hostname}-{deployment}.EXT
...

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

There you can see contents of express-ts-seed-production.json and express-ts-seed.json secrets merged together. You can actually use just 1 file containing full configuration, that’s up to you.

Configuration changes

What about configuration changes? You cannot just update or re-create secrets because:

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

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

Create new secret

New secret must have different name, and here we can use NODE_APP_INSTANCE env variable, which is also used in config file load order.

$ 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

Update service

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

$ docker service update \
    --env-rm "NODE_APP_INSTANCE" \
    --env-add "NODE_APP_INSTANCE=0" \
    --secret-rm "express-ts-seed-production.json" \
    --secret-add "express-ts-seed-production-0.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     

Remove old secret

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

Conclusion

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

Same effect may be achieved with kubernets, k8s secrets are mounted as volumes there so you have flexibility to choose config files path.

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 custom config file directly, ex: NODE_CONFIG_FILE=/path/to/file, but NODE_CONFIG_DIR worked.