Twelve-Factor Applications: How Do You Validate Your Configuration?

Alexis Janvier
Alexis JanvierDecember 05, 2018
#devops#tutorial

Docker has significantly affected the architecture of our applications. If at the beginning we used it to help local development, we now use it in production, too. It's also a great tool to deliver complex applications split by service, as docker images. This type of design provides a lot of flexibility, but also a lot of complexity. The twelve-factor methodology is a reliable reference for building software-as-a-service apps.

This post refers to the third point of these 12 factors: the configuration.

The Problem

The 12 factors guidelines recommend:

a strict separation of config from code. Config varies substantially across deploys, code does not. [..] The twelve-factor app stores config in environment variables.

So far, in production, our twelve-factor applications use either Docker Compose or Docker Swarm on the customer's servers. We did not yet have the opportunity to try Kubernetes. The customer provides us with a registry on which we deliver the images of the services. Together with the customer, we maintain the docker-compose.yml file (or swarm.yml). All the environment variables are managed inside a environmentName.env file, injected into the containers. It's the server's operating manager who is responsible for the content of this file.

// in swarm.yml

version: "3.4"

services:
  service1:
    image: service1
    env_file:
      - ./staging.env
  service2:
    image: service2
    env_file:
      - ./staging.env
  ...

But how do we ensure that the customer's environment variables are all present and valid for each version delivered, while keeping them strictly separate from the delivered code (access to databases, internal web services, etc...)?

Our Current Solution

The simplest solution we have found for the moment is to deliver a specific image whose only role is to validate these environment variables.

And for that, we used a javascript configuration management tool that we have been using for quite some time: convict.

convict allows defining a schema in which a configuration is described as:

VARIABLE_NAME:{
    doc: "Variable description",
    format: "the `format` property specifies either a built-in convict format (`ipaddress`,`port`,`int`, etc.), or it can be a function to check a custom format.",
    default: "Every setting_must_have a default value.",
    env: "If the variable specified by `env` has a value, it will overwrite the setting's default value."
}

The idea is to define all our environment variables in this schema, to describe them with doc, to set them to an empty default value and to systematically fill env.

For example, let's consider that the configuration of our application requires three environment variables NODE_ENV, POSTGRES_PASSWORD and POSTGRES_USER. Here's what the schema can look like:

// in src/config
const convict = require('convict');

const config = convict({
    NODE_ENV: {
        default: '',
        doc: 'The application environment.',
        env: 'NODE_ENV',
        format: ['production', 'development', 'test'],
    },
    POSTGRES_PASSWORD: {
        default: '',
        doc: "PostgreSQL's user password",
        env: 'POSTGRES_PASSWORD',
        format: String,
    },
    POSTGRES_USER: {
        default: '',
        doc: "PostgreSQL's user",
        env: 'POSTGRES_USER',
        format: String,
    },
});

module.exports = config;

Then, the validate convict method applied to an empty configuration file (config.load({})) will ensure that validation is done only on the current environment variables.

// in src/index.js
const signale = require('signale');
const config = require('./config');

const validateConfiguration = () => {
    config.load({});
    try {
        config.validate({ allowed: 'strict' });
        signale.success();
    } catch (error) {
        signale.error(`\\n${error.message}\\n`);
    }
};

Note: signale is used to make the console output more readable.

All that remains is to create a Docker image from the two files index.js and config.js:

// in Dockerfile
FROM node:dubnium-alpine

COPY ./src ./validator
WORKDIR /validator
COPY ./package.json ./package.json
COPY ./yarn.lock ./yarn.lock
RUN yarn install --non-interactive  --frozen-lockfile

CMD ["node", "index.js", "validate"]
docker build -t myapp_conf_validation:latest

It is now possible to launch the validation of the file myenv.env:

docker run --rm -it --env-file=myenv.env myapp_conf_validation:latest

Final result

The code is available on Github

Conclusion

This tool has allowed us to improve collaboration with our customers teams. It has also helped us avoid several errors during deployments. From this perspective, it's a good tool because it solves a problem.

But, it's still very imperfect. It cannot be integrated into a complete deployment automation. If it's possible to use it in a script to secure a deployment, for example in a makefile recipe, it remains a manual procedure.

// in makefile
deploy: ## Check current conf file before deploy latest images
    docker pull myapp_conf_validation:latest
    docker run --rm -it --env-file=myenv.env myapp_conf_validation:latest
    docker stack deploy myStack

Overall, what really prevents it from being used in a real continuous deployment process is the way we manage environment variables: in a file maintained only by the ops team.

On your side, how do you validate your configuration for your twelve-factor applications?