Environment Variables & Configuration: Building Maintainable Applications

Master environment variables, config files, secrets management and multi-environment deployment

Environment Variables & Configuration: Building Maintainable Applications

Configuration management is the foundation of maintainable applications. This article explores best practices for environment variables and configuration.

Configuration Overview

Configuration Hierarchy

Configuration Priority (high to low):
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Command Line Arguments                            │
│   └── node app.js --port=3000                      │
│                                                     │
│   Environment Variables                             │
│   └── PORT=3000 node app.js                        │
│                                                     │
│   .env Files                                        │
│   └── .env.local > .env.production > .env          │
│                                                     │
│   Config Files                                      │
│   └── config.json / config.ts                      │
│                                                     │
│   Default Values                                    │
│   └── Hardcoded defaults in code                   │
│                                                     │
└─────────────────────────────────────────────────────┘
Config TypeUse Case
Env VariablesSensitive info, deployment differences
Config FilesComplex config, version controlled
DefaultsDevelopment convenience, sensible defaults

Environment Variable Basics

.env Files

# .env - Base configuration
NODE_ENV=development
PORT=3000
API_URL=http://localhost:8080

# .env.development - Development environment
DATABASE_URL=postgres://localhost:5432/dev
DEBUG=true

# .env.production - Production environment
DATABASE_URL=postgres://prod-server:5432/prod
DEBUG=false

# .env.local - Local overrides (not committed to Git)
API_KEY=my-secret-key

dotenv Loading

// Using dotenv
import dotenv from 'dotenv';

// Load default .env
dotenv.config();

// Load specific environment
dotenv.config({
  path: `.env.${process.env.NODE_ENV}`,
});

// Multi-file loading
import dotenvExpand from 'dotenv-expand';

const myEnv = dotenv.config();
dotenvExpand.expand(myEnv);

// Variable expansion example
// .env file:
// BASE_URL=https://api.example.com
// API_ENDPOINT=${BASE_URL}/v1

Accessing Environment Variables

// Node.js
const port = process.env.PORT;
const apiUrl = process.env.API_URL;

// With defaults
const port = process.env.PORT || 3000;
const debug = process.env.DEBUG === 'true';

// Required environment variables
function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

const apiKey = requireEnv('API_KEY');

Type-Safe Configuration

Zod Schema Validation

import { z } from 'zod';

// Define config schema
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  DEBUG: z.coerce.boolean().default(false),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  CORS_ORIGINS: z
    .string()
    .transform((s) => s.split(','))
    .default('*'),
});

// Validate and export
function validateEnv() {
  const parsed = envSchema.safeParse(process.env);

  if (!parsed.success) {
    console.error('❌ Invalid environment variables:');
    console.error(parsed.error.format());
    throw new Error('Invalid environment variables');
  }

  return parsed.data;
}

export const env = validateEnv();

// Usage
console.log(env.PORT);        // number
console.log(env.DEBUG);       // boolean
console.log(env.CORS_ORIGINS); // string[]

TypeScript Type Declarations

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'production' | 'test';
    PORT?: string;
    DATABASE_URL: string;
    API_KEY: string;
    DEBUG?: string;
  }
}

// Or extend @types/node
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      // ...other variables
    }
  }
}

Frontend Environment Variables

Vite Environment Variables

// vite.config.ts
import { defineConfig, loadEnv } from 'vite';

export default defineConfig(({ mode }) => {
  // Load environment variables
  const env = loadEnv(mode, process.cwd(), '');

  return {
    define: {
      'process.env.API_URL': JSON.stringify(env.VITE_API_URL),
    },
  };
});

// .env
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=My App

// Usage (must have VITE_ prefix)
console.log(import.meta.env.VITE_API_URL);
console.log(import.meta.env.VITE_APP_TITLE);
console.log(import.meta.env.MODE); // development | production
console.log(import.meta.env.DEV);  // boolean
console.log(import.meta.env.PROD); // boolean

Next.js Environment Variables

// .env.local
# Server-side variables
DATABASE_URL=postgres://localhost:5432/db
API_SECRET=secret

# Client-side variables (must have NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=UA-123456

// Server-side access
export async function getServerSideProps() {
  const dbUrl = process.env.DATABASE_URL; // ✅
  const apiUrl = process.env.NEXT_PUBLIC_API_URL; // ✅
  return { props: {} };
}

// Client-side access
function Component() {
  const apiUrl = process.env.NEXT_PUBLIC_API_URL; // ✅
  const dbUrl = process.env.DATABASE_URL; // ❌ undefined
}

Astro Environment Variables

// .env
# Private variables (server only)
DATABASE_URL=postgres://localhost:5432/db

# Public variables (client accessible)
PUBLIC_API_URL=https://api.example.com

// astro.config.mjs
export default defineConfig({
  vite: {
    define: {
      'process.env.CUSTOM_VAR': JSON.stringify(process.env.CUSTOM_VAR),
    },
  },
});

// Usage
import.meta.env.PUBLIC_API_URL; // Client accessible
import.meta.env.DATABASE_URL;    // Server only

Multi-Environment Configuration

Configuration Manager

// config/index.ts
interface Config {
  env: string;
  port: number;
  database: {
    url: string;
    poolSize: number;
  };
  redis: {
    url: string;
  };
  api: {
    baseUrl: string;
    timeout: number;
  };
  features: {
    enableNewFeature: boolean;
  };
}

const configs: Record<string, Partial<Config>> = {
  development: {
    database: {
      url: 'postgres://localhost:5432/dev',
      poolSize: 5,
    },
    features: {
      enableNewFeature: true,
    },
  },
  production: {
    database: {
      url: process.env.DATABASE_URL!,
      poolSize: 20,
    },
    features: {
      enableNewFeature: false,
    },
  },
  test: {
    database: {
      url: 'postgres://localhost:5432/test',
      poolSize: 1,
    },
  },
};

const defaultConfig: Config = {
  env: process.env.NODE_ENV || 'development',
  port: Number(process.env.PORT) || 3000,
  database: {
    url: '',
    poolSize: 10,
  },
  redis: {
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  },
  api: {
    baseUrl: process.env.API_URL || 'http://localhost:8080',
    timeout: 30000,
  },
  features: {
    enableNewFeature: false,
  },
};

function getConfig(): Config {
  const env = process.env.NODE_ENV || 'development';
  const envConfig = configs[env] || {};

  return deepMerge(defaultConfig, envConfig) as Config;
}

export const config = getConfig();

Feature Flags

// features.ts
interface FeatureFlags {
  newDashboard: boolean;
  darkMode: boolean;
  betaFeatures: boolean;
  maintenanceMode: boolean;
}

function loadFeatureFlags(): FeatureFlags {
  return {
    newDashboard: process.env.FEATURE_NEW_DASHBOARD === 'true',
    darkMode: process.env.FEATURE_DARK_MODE !== 'false', // Default enabled
    betaFeatures: process.env.FEATURE_BETA === 'true',
    maintenanceMode: process.env.MAINTENANCE_MODE === 'true',
  };
}

export const features = loadFeatureFlags();

// Usage
if (features.newDashboard) {
  // New feature code
}

Security Best Practices

.gitignore Configuration

# Environment files
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local

# Sensitive config
config/secrets.json
*.pem
*.key

Environment Variable Validation

// Validate all required env vars at startup
function validateRequiredEnvVars() {
  const required = [
    'DATABASE_URL',
    'API_KEY',
    'JWT_SECRET',
  ];

  const missing = required.filter((name) => !process.env[name]);

  if (missing.length > 0) {
    console.error('Missing required environment variables:');
    missing.forEach((name) => console.error(`  - ${name}`));
    process.exit(1);
  }
}

// Call before app starts
validateRequiredEnvVars();

Sensitive Information Handling

// Don't log sensitive information
function logConfig(config: Config) {
  const safeConfig = {
    ...config,
    database: {
      ...config.database,
      url: maskConnectionString(config.database.url),
    },
    apiKey: '***',
  };

  console.log('Configuration:', safeConfig);
}

function maskConnectionString(url: string): string {
  try {
    const parsed = new URL(url);
    if (parsed.password) {
      parsed.password = '***';
    }
    return parsed.toString();
  } catch {
    return '***';
  }
}

Best Practices Summary

Environment Configuration Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Security                                          │
│   ├── Never commit sensitive info to Git           │
│   ├── Use .env.example as template                 │
│   ├── Validate required vars at startup            │
│   └── Mask sensitive info in logs                  │
│                                                     │
│   Maintainability                                   │
│   ├── Use type-safe configuration                  │
│   ├── Provide sensible defaults                    │
│   ├── Document all config options                  │
│   └── Use layered config structure                 │
│                                                     │
│   Deployment                                        │
│   ├── Environment isolation                        │
│   ├── Use config services or secrets management   │
│   ├── Support hot-reload for config               │
│   └── Version control non-sensitive config        │
│                                                     │
└─────────────────────────────────────────────────────┘
ScenarioRecommendation
Local development.env.local overrides
CI/CDEnvironment variable injection
Production deploymentSecrets management service
Config validationZod Schema

Good configuration management ensures your app runs correctly in any environment.