Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool

Multi-Environment JSON Configuration Management

Modern applications rarely run in just one setting. Developers work in a development environment, test in staging or QA, and deploy to production. Each environment often requires slightly (or significantly) different configuration settings — database credentials, API endpoints, feature flags, logging levels, etc. Managing these differences efficiently and safely is crucial. JSON files are a popular choice for storing configuration due to their readability and widespread support.

This article explores common patterns and best practices for managing these environment-specific JSON configurations.

Why JSON for Configuration?

JSON (JavaScript Object Notation) is an ideal format for configuration because:

  • Human-Readable: The key-value structure is easy for developers to read and write.
  • Machine-Readable: Easily parsed by virtually every programming language.
  • Hierarchical Structure: Supports nested objects and arrays, allowing for organized configurations.
  • Wide Tooling Support: Many editors provide JSON syntax highlighting and validation.

Common Approaches

Let's look at a few standard methods for handling environment-specific JSON configs.

1. Multiple Environment-Specific Files

This is perhaps the most straightforward approach. You create a separate JSON file for each environment.

File Structure Example:

/config
├── config.development.json
├── config.staging.json
└── config.production.json

config.development.json:

{
  "apiEndpoint": "http://localhost:3000/api",
  "logLevel": "debug",
  "featureFlags": {
    "newUserProfile": true,
    "betaFeatures": true
  },
  "database": {
    "host": "localhost",
    "port": 5432,
    "db": "app_dev"
    // Note: Credentials should ideally come from env vars, not config files
  }
}

config.production.json:

{
  "apiEndpoint": "https://api.yourproductiondomain.com/api",
  "logLevel": "info",
  "featureFlags": {
    "newUserProfile": true,
    "betaFeatures": false
  },
  "database": {
    "host": "prod-db.yourcloud.com",
    "port": 5432,
    "db": "app_prod"
    // Again, credentials via env vars
  }
}

How to Load the Correct File:

In your application code (typically server-side or during a build process), you determine the current environment (usually via the NODE_ENV environment variable) and load the corresponding file.

Conceptual Loading Logic (TypeScript/Node.js):

// This logic would typically run once during application startup
import * as fs from 'fs';
import * as path from 'path';

const env = process.env.NODE_ENV || 'development'; // Default to development
const configFileName = `config.${env}.json`;
const configPath = path.join(process.cwd(), 'config', configFileName);

let config: any;

try {
  const configFileContent = fs.readFileSync(configPath, 'utf-8');
  config = JSON.parse(configFileContent);
  // console.log(`Loaded config for environment: ${env}`);
} catch (error: any) {
  console.error(`Error loading configuration file ${configPath}: ${error.message}`);
  // Handle the error - maybe exit or load a fallback config
  // process.exit(1);
}

// Now 'config' holds the settings for the current environment
// Example usage:
// const dbHost = config.database.host;
// const api = config.apiEndpoint;

// To make it globally accessible (caution needed in large apps):
// export default config;

// Or wrap it in a function to get config:
// export function getConfig() {
//   if (!config) {
//     // Optional: Implement loading logic here if not done at startup
//     console.error("Config not loaded yet!");
//     throw new Error("Configuration not available.");
//   }
//   return config;
// }

Pros: Simple to understand, clear separation per environment.
Cons: Duplication of common settings across files, easy to forget updating all files when a new setting is added.

2. Single JSON File with Environment Keys

Keep all configurations in one file, nested under keys representing each environment.

config.json:

{
  "development": {
    "apiEndpoint": "http://localhost:3000/api",
    "logLevel": "debug",
    "featureFlags": {
      "newUserProfile": true,
      "betaFeatures": true
    },
    "database": {
      "host": "localhost",
      "port": 5432,
      "db": "app_dev"
    }
  },
  "production": {
    "apiEndpoint": "https://api.yourproductiondomain.com/api",
    "logLevel": "info",
    "featureFlags": {
      "newUserProfile": true,
      "betaFeatures": false
    },
    "database": {
      "host": "prod-db.yourcloud.com",
      "port": 5432,
      "db": "app_prod"
    }
  }
  // ... other environments
}

How to Load the Correct Settings:

Load the single file and access the nested object matching the current environment.

Conceptual Loading Logic (TypeScript/Node.js):

// This logic would typically run once during application startup
import * as fs from 'fs';
import * as path from 'path';

const env = process.env.NODE_ENV || 'development'; // Default to development
const configPath = path.join(process.cwd(), 'config', 'config.json');

let config: any;
let environmentConfig: any;

try {
  const configFileContent = fs.readFileSync(configPath, 'utf-8');
  config = JSON.parse(configFileContent);

  // Get the configuration object for the current environment
  environmentConfig = config[env];

  if (!environmentConfig) {
      throw new Error(`Configuration for environment "${env}" not found in ${configPath}`);
  }

  // console.log(`Loaded config for environment: ${env}`);
} catch (error: any) {
  console.error(`Error loading or parsing configuration from ${configPath}: ${error.message}`);
  // Handle the error
  // process.exit(1);
}

// Now 'environmentConfig' holds the settings for the current environment
// Example usage:
// const dbHost = environmentConfig.database.host;
// const api = environmentConfig.apiEndpoint;

// To make it globally accessible:
// export default environmentConfig;

// Or wrap it in a function:
// export function getConfig() {
//   if (!environmentConfig) {
//      console.error("Environment config not loaded yet!");
//      throw new Error("Environment configuration not available.");
//   }
//   return environmentConfig;
// }

Pros: All configurations in one place, easy to compare settings across environments.
Cons: File can become very large and difficult to manage with many environments or settings, harder to use configuration cascading (e.g., a base config plus environment overrides).

3. Base Config with Environment Overrides

Combine the previous two approaches by having a base configuration file with common settings, and environment-specific files that provide overrides or add new settings.

File Structure Example:

/config
├── config.base.json
├── config.development.json
└── config.production.json

config.base.json:

{
  "logLevel": "warn", // Default log level
  "timeoutMs": 5000,
  "featureFlags": {
    "newUserProfile": true, // Enabled by default
    "betaFeatures": false   // Disabled by default
  }
}

config.development.json:

Overrides/adds settings from config.base.json

{
  "apiEndpoint": "http://localhost:3000/api",
  "logLevel": "debug", // Override base
  "featureFlags": {
    "betaFeatures": true // Override base
  },
  "database": { // Add new section
    "host": "localhost",
    "port": 5432,
    "db": "app_dev"
  }
}

How to Load and Merge:

Load the base config first, then load the environment-specific config and merge it on top (environment settings override base settings).

Conceptual Loading & Merging Logic (TypeScript/Node.js):

// This logic would typically run once during application startup
import * as fs from 'fs';
import * as path from 'path';
// You would need a utility function for deep merging objects
// import merge from 'lodash.merge'; // Example with a library

// Simple deep merge example (handle objects, arrays might need more complex logic)
function deepMerge(target: any, source: any): any {
    const output = { ...target }; // Start with a copy
    if (source && typeof source === 'object') {
        Object.keys(source).forEach(key => {
            if (source[key] && typeof source[key] === 'object' && target[key] && typeof target[key] === 'object') {
                output[key] = deepMerge(target[key], source[key]);
            } else {
                output[key] = source[key]; // Override or add primitive/array/null values
            }
        });
    }
    return output;
}


const env = process.env.NODE_ENV || 'development'; // Default to development
const baseConfigPath = path.join(process.cwd(), 'config', 'config.base.json');
const envConfigPath = path.join(process.cwd(), 'config', `config.${env}.json`);

let config: any;

try {
  const baseConfigContent = fs.readFileSync(baseConfigPath, 'utf-8');
  const baseConfig = JSON.parse(baseConfigContent);

  const envConfigContent = fs.readFileSync(envConfigPath, 'utf-8');
  const envConfig = JSON.parse(envConfigContent);

  // Merge environment config on top of base config
  config = deepMerge(baseConfig, envConfig);

  // console.log(`Loaded and merged config for environment: ${env}`);
} catch (error: any) {
  console.error(`Error loading or merging configuration: ${error.message}`);
  // Handle the error
  // process.exit(1);
}

// Now 'config' contains the merged settings
// Example usage:
// const dbHost = config.database.host; // From env file
// const timeout = config.timeoutMs; // From base file
// const betaFeatures = config.featureFlags.betaFeatures; // Overridden in env file

// To make it globally accessible:
// export default config;

// Or wrap it in a function:
// export function getConfig() {
//   if (!config) {
//      console.error("Merged config not loaded yet!");
//      throw new Error("Configuration not available.");
//   }
//   return config;
// }

Pros: Reduces duplication, common settings are in one place, clear separation of base and overrides.
Cons: Requires a merging mechanism (simple merge for top-level keys, or a deep merge function/library for nested structures).

Handling Sensitive Data: Configuration vs. Secrets

A critical best practice is separating non-sensitive configuration (like API endpoints, feature flags, log levels) from sensitive secrets (like database passwords, API keys, encryption keys).

Never commit secrets to Git!

Configuration files checked into version control (like Git) should never contain sensitive information. These files are part of your codebase and could be accessed by anyone with access to the repository.

Sensitive data should be managed using environment variables. These variables are set on the server or in the deployment environment itself, outside of your application code and configuration files.

Using Environment Variables:

Access environment variables via process.env in Node.js.

// Inside your application code where you need database credentials
const dbUser = process.env.DB_USER;
const dbPassword = process.env.DB_PASSWORD;
const dbHost = config.database.host; // Host might still come from config file

if (!dbUser || !dbPassword) {
    console.error("Database credentials environment variables not set!");
    // Handle missing credentials
    // process.exit(1);
}

// Use dbUser and dbPassword to connect to the database at dbHost

You can combine environment variables with JSON configurations. For example, your JSON might define the database schema or connection options, but the user/password come from process.env.

Tools like dotenv can help manage environment variables in development by loading them from a .env file, but you should rely on your hosting provider's mechanism for production environment variables.

Configuration Validation

As configurations grow, it's easy to make mistakes — typos in keys, incorrect data types, missing required settings. Implementing validation helps catch these errors early.

You can use JSON Schema or validation libraries (like Zod, Joi, or Yup) to define the expected structure and types of your configuration. Validate the loaded configuration object after parsing the JSON file(s).

Conclusion

Managing configurations across multiple environments is a fundamental aspect of building production-ready applications. Using JSON files provides a structured and readable way to define settings. The best approach (multiple files, single file with keys, or base with overrides) often depends on the complexity and number of your environments and settings.

Regardless of the file structure you choose, remember the critical rule: secrets belong in environment variables, not committed configuration files. Implementing validation adds an extra layer of robustness to ensure your application starts with the correct settings in every environment.

Need help with your JSON?

Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool