Type-Safe Node.js app configuration with Docker secrets
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:
Our app will probably need more of such configurations so we can compose them into single app config type:
Next we will have to define our validation rules for the config types:
And finally load the config and export it to its users:
If the loaded config will fail to match the defined requirements it will throw an exception.
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:
Creating docker secrets
Let’s check the available secrets:
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.
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:
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:
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.
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.
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 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.