DEV Community

Matt
Matt

Posted on

A better way to do environment variables

Running software will always require configuration values to declaratively steer the outcome of its functionality.

We have traditionally utilised environment variables to handle all of our important values. Whilst it works and there are benefits, it has become a very inflexible way to build software in light of evolved patterns.

Using things like secrets manager services, feature flag services and config files, we can accelerate our development with more confidence and care.

Bondi, Larry Snickers

Current State & Criticisms

Take this example snippet from a small imaginary TypeScript monorepo consisting of a website using Vite-config, Node backend and utilising environment variables to manage all build-time and runtime outcomes:

.env.staging

Note: assume this will be set properly upon deployment and not using files

ENV=staging
NODE_ENV=production
VITE_TITLE="Medium"
FEATURE_1_ENABLED=true
SOME_SECRET=xxx
VITE_API_URL=api.medium.com
Enter fullscreen mode Exit fullscreen mode

api.ts

const { 
  ENV, 
  NODE_ENV, 
  FEATURE_1_ENABLED,
  SOME_SECRET,
} = process.env;
Enter fullscreen mode Exit fullscreen mode

app.ts

const { 
  VITE_TITLE,
  VITE_API_URL,
} = import.meta.env;
Enter fullscreen mode Exit fullscreen mode

Let's take a closer look at the environment variables:

# required at build-time, boot-time & runtime
# because of this, values like these will always need to be environment 
# variables
ENV=staging
NODE_ENV=production

# static, frontend-only & non-sensitive values
VITE_TITLE="Medium"
VITE_API_URL=api.medium.staging

# this is a non-sensitive, runtime boolean value
# feature flags should ideally be dynamic and personalised
FEATURE_1_ENABLED=true

# this is a sensitive runtime value
SOME_SECRET=xxx
Enter fullscreen mode Exit fullscreen mode

This looks fine, so what are the drawbacks here?

Developer Experience & Confidence

You could imagine in a large complex product, this file could move into the 100's of lines. Now multiply this by the amount of environments you have and things can more easily go wrong. These are testable, of course, but since they are coming from the environment the tests are being run in, rather than the code or a service, it's environment dependent.

Security

Yes, someone needs access to your server to see these values. What if someone is testing a production build locally and accidentally commits a secret value? Some IDE's store local history for weeks.

Static vs Dynamic

Changing environment variables on a server is the only option we have here and will require a service restart in most cases, whilst 'feature flag' type values should have the ability to be dynamically changed on the fly i.e., from an admin interface or be personalised for workspaces or users through a session variable.

Deployment

Where do these values get set? For local dev, it's right in front of us, but when we deploy our environment, we begin to set most of these in some separate platform, like a CI/CD pipeline config, which is away from our codebase and becomes a bit of a black box.

Icebergs, Belle Co

Solution

tl;dr do these steps

  1. Categorise your values:
    i. environment values
    ii. secret values
    iii. non-secret values
    iv. dynamic & personalised values

  2. Use a typed environment variable file

  3. Use a secrets manager service

  4. Use code config files that are apart of your codebase (file per environment)

  5. Use a feature flag service

Let's go deeper!

1. Categorising values

This will help you understand where you should move your existing environment variables to.

i. Environment Values

Environment values are values that declaratively tell the service or app how it should run in the given environment.

Traits

  • required at build time
  • required at boot time
  • often required during runtime
  • not sensitive
  • immutable

Examples

  • environment determinant e.g., Node Environment is production NODE_ENV=production

ii. Secret values

Traits

  • sometimes required at build time
  • required at boot time
  • required during runtime
  • sensitive
  • potentially mutable, generally immutable

Examples

  • third-party API keys e.g., Stripe API Key STRIPE_API_KEY=abc-123

iii. Static, non-secret values

Traits

  • required at build time
  • required during runtime
  • not sensitive
  • potentially mutable, generally immutable

Examples

  • UI content variables e.g., meta page title PAGE_TITLE=My Website
  • Feature flag value e.g., toggling a new feature on in certain environment NEW_SEARCH_BAR_UI_ENABLED=false

iv. Dynamic, personalised values

Traits

  • required at build time
  • required during runtime
  • not sensitive
  • mutable

Examples

  • Feature previews e.g., subset of organisations in your app can preview a new feature before the rest of the users can e.g., NEW_SEARCH_BAR_UI_ENABLED=true

2. Use a typed environment variable file

Let's say this is what our environment variables look like:

Environment (however you set this):

DEBUG=false
DEPLOYMENT=staging
NODE_ENV=production
Enter fullscreen mode Exit fullscreen mode

Since we will always need some values from the environment, the least we can do is wrap some strictness around the types for confidence.

Using TypeScript as an example, we can create an environment.d.ts file so we get completion and type safety when accessing process.env:

environment.d.ts:

declare namespace NodeJS {
  export interface ProcessEnv {
    DEBUG: string;
    DEPLOYMENT: string;
    NODE_ENV: string;
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep in mind that they are all string | undefined in Typescript, whether or not you have assigned these to a number, boolean or otherwise, e.g.,

if (process.env.DEBUG === 'false') { ...

if (!process.env.DEBUG) { ...

znv is pretty good at improving the types of your environment also.

3. Secrets Manager service

I would suggest starting with these as environment variables as they change infrequently.

However, there may come a time where you want these to be dynamic, in the sense that you want to change them without incurring any downtime. For this, we can use a Secrets Manager service.

Off the shelf Secret Manager services can get expensive, so you may want to roll your own, however, the responsibility of designing the encryption process, accessor patterns and probably an admin UI now rely with your team.

4. Config files

When infrastructure-as-a-service and infrastructure-as-code became a reality, there was a notion that we could have 1..n environments. Whilst this is still true, things like feature flagging helped us move code to production in a safer manner, since we could obfuscate new features from users until they were ready and still deliver new features, fixes and technical refreshes.

With this paradigm shift, lot's of teams moved toward trunk-based development. In doing so, we have come full-circle in a way, way back to a time where we had multiple physical servers for the various stages of SDLC; staging and production.

  • staging: production-like environment where we can sanity test new code. This generally reflects the code in main before production does.
  • production: the thing customers use

Additional environments can include:

  • development: a safe space to perform architecture or third-party changes without disrupting the flow of all code to production. This is generally for developers only.
  • local: for local development
  • test: for test-running environments

Given the above, we can have configuration files which store safe values for each environment using types:

Note: as this grows, separate files would be welcome

config/getConfigValue.ts

// we can separate this into a `staging.ts` file
const staging: ConfigKeys = {
    PAGE_TITLE: 'My website',
    NEW_SEARCH_BAR_UI_ENABLED: true
}

// we can separate this into a `production.ts` file
const production: ConfigKeys = {
    PAGE_TITLE: 'My website',
    NEW_SEARCH_BAR_UI_ENABLED: false
}

// type safe config
type ConfigKeys = {
    PAGE_TITLE: string;
    NEW_SEARCH_BAR_UI_ENABLED: boolean;
};

type Config = keyof ConfigKeys;

// we can add as many environments as we need, even a 'default' environment for fallback (although this could be production for safe use in most cases)
type DeploymentEnvironment =
    | 'staging'
    | 'production';

type ConfigKeysForEnv = Record<DeploymentEnvironment, ConfigKeys | undefined>;

const configKeysForEnv: ConfigKeysForEnv = {
    staging,
    production,
};

export function getConfigValue<T extends Config>(
    key: T,
    deploymentEnvironment: DeploymentEnvironment,
    override?: ConfigKeys[T]
): ConfigKeys[T] {
    const deployment = deploymentEnvironment || 'production';

    if (!(deployment in configKeysForEnv)) {
        throw new Error(
            `Unsupported deployment environment provided for config: ${deploymentEnvironment}`
        );
    }

    const config = configKeysForEnv[deployment];
    if (!config || !(key in config)) {
        throw new Error(
            `Config key does not exist for deployment environment: ${key}`
        );
    }

    const value = config[key];

    if (override !== undefined) {
        return override;
    }

    return value;
}
Enter fullscreen mode Exit fullscreen mode

Yeah yeah, development 'code' next to production 'code', there are trade-offs.

5. Feature Flag service

Conclusion

Let me know how you go moving away from using environment variables for everything.

Please feel free to leave any feedback as I'm always happy to chat about different opinions & experiences and learn new things.

Note: I like using a common image theme for my posts so whilst Bondi makes no sense in a technical blog, nerd talk and code by itself would be boring.

Downsides to this, multi language environment will need to incorporate independent implementations per language. Whilst I can say this is the cost of doing business, properly, this is overhead
Security
Testable
testing production out locally
let's other people in your team who are not technical manage the commercial side of the product re: feature flags
production secrets being committed

I might be a minority and a bit bias, but low code / no-code solutions generally irk me and I would often suggest avoiding them. Like tennis, playing in the middle is risky.

but but but development values in production and vice versa

Run time will pick up env var change

Overrides for an env in db (singleton table) and overrides at workspace and even user level

Current impls

Dev has changed due to this evolution

Low code no code sucks

Dynamic data > low code

Top comments (0)