TL;DR — check out the example here
Configuration used to be easy:
- config.dev.js
- config.prod.js
- config.test.js
and done. Except that those weren't environments as we have them today (k8s, Travis, Compose, to name a few).
Since early 2017 we tried out various ways to work with environment variables and configuration to achieve a Twelve-Factor-App:
The twelve-factor app stores config in environment variables
The Environment Issue
When working with node.js and the package.json-file, our biggest issue was the local development machine, as it’s supposed to represent two different environments:
- development
- test
The problem starts with the most basic environment variable that every Node.js
developer should know: NODE_ENV
. On our machines, we use development
and on
your Continuous Integration (CI) platform we use test
as NODE_ENV
.
Initially, we just added the NODE_ENV
to the test script in package.json
:
“script”: {“test”: “NODE_ENV=test tape *-test.js”}
This might work for a while, but then you might want to add a different
LOG_LEVEL
for test and maybe a different DATABASE_HOST
and you quickly have
a dozen envrionment variables in your package.json. Those variables might be
working out on your machine, but how about the CI?
There’s two possible solutions to improve working with environment variables: direnv and dotenv.
direnv
direnv
was our first choice, as it automatically loads the variables from a
.envrc
file the moment you cd
into that directory. .envrc
looks like this:
export LOG_LEVEL=errorexport NODE_ENV=development
This works great, until you start using docker-compose. In docker-compose, you
can
load default configuration from an .env
file,
which is the same as the .envrc
file above with one tiny exception: it doesn’t
work with ‘export’. The docker-compose env-file needs to look like the example
below and without the ‘export’ keyword, direnv won’t work:
LOG_LEVEL=errorNODE_ENV=development
dotenv
Next, we tried an npm module called
dotenv which can work with the same
files & format as docker-compose, but it requiresrequire(‘dotenv’).config()
as
a first line in your app. This gets annoying when you write unit tests, as you
need to start every test with dotenv. You can
preload dotenv in your script,
but that means this would also be executed on a “real” test environment like
Travis, not just your localhost. Overall it got a little bit too messy, so we
had a look at Makefiles.
The Node.js Makefile
#!makeMAKEFLAGS += --silentinclude .envexport $(shell sed 's/=.*//' .env)dev:node_modules/.bin/nodemon index.jstest:NODE_ENV=test \LOG_ENABLED=false \LOG_LEVEL=silent \npm testwatch:node_modules/.bin/chokidar 'test/**/*.js' -c 'node_modules/.bin/tape {path}'.PHONY: test.PHONY: dev.PHONY: watch
The environment variables in .env
are your default for development purposes
and loaded into the process via include
and export
(found on StackExchange). As a
developer, I can run make dev
to run the app in my local development
environment with debugging and all the settings defined in my .env
-file. I can
also watch the test
folder for changes and automatically run the tests with
make watch
- but still have logging etc. as it would be my development
environment. Maybe I have PostgreSQL installed locally and use localhost
as
hostname for development. If I want to run the app as part of a docker-compose
system, I overwrite the database host:
env_file: .envenvironment:- POSTGRES_HOST=postgres
This allows two clean and separated environments on your development machine, while not polluting the codebase or package.json with configuration or code that belongs into the environment.
We decided not to provide any defaults (ie.
process.env.NODE_ENV || 'development'
), that means for example if LOG_LEVEL
isn’t set, the app crashes. This is a great way to make sure the environment is
ready before the app is launched and there’s no unexpected behaviour.
And the best part is, the environment variables are only set for the execution
of the script, so you won’t have any ADMIN_PASSWORD
's or other secrets in your
Terminal process that someone could read via echo $PASSWORD
.
We’ve put together a minimal example here, feel free to open an issue or pull request.
If you have any questions or comments, please reach out on Twitter or start a discussion on GitHub.