Environment Management

Patterns for managing multiple deployment environments.

Overview#

Proper environment management ensures safe deployments and configuration. This pattern covers:

  • Environment variable structure
  • Environment-specific configuration
  • Feature flags per environment
  • Secret management
  • Preview environments

Prerequisites#

npm install zod

Code Example#

Environment Variable Schema#

1// lib/env.ts 2import { z } from 'zod' 3 4const envSchema = z.object({ 5 // Runtime environment 6 NODE_ENV: z.enum(['development', 'production', 'test']), 7 VERCEL_ENV: z.enum(['development', 'preview', 'production']).optional(), 8 9 // Database 10 DATABASE_URL: z.string().url(), 11 12 // Auth 13 NEXTAUTH_SECRET: z.string().min(32), 14 NEXTAUTH_URL: z.string().url(), 15 16 // API URLs 17 NEXT_PUBLIC_APP_URL: z.string().url(), 18 NEXT_PUBLIC_API_URL: z.string().url().optional(), 19 20 // Third-party services 21 STRIPE_SECRET_KEY: z.string().startsWith('sk_'), 22 STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'), 23 RESEND_API_KEY: z.string().startsWith('re_'), 24 25 // Feature flags 26 ENABLE_ANALYTICS: z.coerce.boolean().default(false), 27 ENABLE_BETA_FEATURES: z.coerce.boolean().default(false) 28}) 29 30function validateEnv() { 31 const parsed = envSchema.safeParse(process.env) 32 33 if (!parsed.success) { 34 console.error('Invalid environment variables:') 35 console.error(JSON.stringify(parsed.error.flatten().fieldErrors, null, 2)) 36 throw new Error('Invalid environment configuration') 37 } 38 39 return parsed.data 40} 41 42export const env = validateEnv() 43export type Env = z.infer<typeof envSchema>

Environment Files Structure#

1# .env.example - Template for developers 2DATABASE_URL="postgresql://localhost:5432/myapp" 3NEXTAUTH_SECRET="your-secret-here" 4NEXTAUTH_URL="http://localhost:3000" 5NEXT_PUBLIC_APP_URL="http://localhost:3000" 6 7# .env.local - Local development (git ignored) 8DATABASE_URL="postgresql://localhost:5432/myapp_dev" 9NEXTAUTH_SECRET="dev-secret-32-characters-long!!" 10NEXTAUTH_URL="http://localhost:3000" 11NEXT_PUBLIC_APP_URL="http://localhost:3000" 12ENABLE_BETA_FEATURES="true" 13 14# .env.test - Test environment 15DATABASE_URL="postgresql://localhost:5432/myapp_test" 16NEXTAUTH_SECRET="test-secret-32-characters-long!!" 17 18# .env.production - Production values (git ignored, in CI secrets) 19# Set via Vercel dashboard or CI/CD secrets

Environment-Aware Configuration#

1// lib/config.ts 2import { env } from './env' 3 4interface Config { 5 isProduction: boolean 6 isDevelopment: boolean 7 isPreview: boolean 8 baseUrl: string 9 apiUrl: string 10 features: { 11 analytics: boolean 12 betaFeatures: boolean 13 debugMode: boolean 14 } 15 rateLimit: { 16 window: number 17 max: number 18 } 19} 20 21export function getConfig(): Config { 22 const isProduction = env.NODE_ENV === 'production' && 23 env.VERCEL_ENV === 'production' 24 const isDevelopment = env.NODE_ENV === 'development' 25 const isPreview = env.VERCEL_ENV === 'preview' 26 27 return { 28 isProduction, 29 isDevelopment, 30 isPreview, 31 baseUrl: env.NEXT_PUBLIC_APP_URL, 32 apiUrl: env.NEXT_PUBLIC_API_URL ?? env.NEXT_PUBLIC_APP_URL, 33 features: { 34 analytics: isProduction && env.ENABLE_ANALYTICS, 35 betaFeatures: !isProduction || env.ENABLE_BETA_FEATURES, 36 debugMode: isDevelopment 37 }, 38 rateLimit: { 39 window: isProduction ? 60 : 1, 40 max: isProduction ? 100 : 1000 41 } 42 } 43} 44 45export const config = getConfig()

Dynamic Base URL#

1// lib/url.ts 2export function getBaseUrl(): string { 3 // Browser 4 if (typeof window !== 'undefined') { 5 return '' 6 } 7 8 // Vercel preview deployments 9 if (process.env.VERCEL_URL) { 10 return `https://${process.env.VERCEL_URL}` 11 } 12 13 // Production 14 if (process.env.NEXT_PUBLIC_APP_URL) { 15 return process.env.NEXT_PUBLIC_APP_URL 16 } 17 18 // Local development 19 return `http://localhost:${process.env.PORT ?? 3000}` 20} 21 22// Usage in tRPC, API routes, etc. 23const baseUrl = getBaseUrl()

Feature Flags by Environment#

1// lib/features.ts 2import { env } from './env' 3 4interface FeatureFlags { 5 newDashboard: boolean 6 aiFeatures: boolean 7 betaCheckout: boolean 8 darkMode: boolean 9} 10 11const featuresByEnvironment: Record<string, Partial<FeatureFlags>> = { 12 development: { 13 newDashboard: true, 14 aiFeatures: true, 15 betaCheckout: true, 16 darkMode: true 17 }, 18 preview: { 19 newDashboard: true, 20 aiFeatures: true, 21 betaCheckout: true, 22 darkMode: true 23 }, 24 production: { 25 newDashboard: false, // Gradual rollout 26 aiFeatures: true, 27 betaCheckout: false, 28 darkMode: true 29 } 30} 31 32export function getFeatureFlags(): FeatureFlags { 33 const environment = env.VERCEL_ENV ?? env.NODE_ENV 34 const flags = featuresByEnvironment[environment] ?? {} 35 36 return { 37 newDashboard: false, 38 aiFeatures: false, 39 betaCheckout: false, 40 darkMode: true, 41 ...flags 42 } 43} 44 45export const features = getFeatureFlags() 46 47// Usage 48if (features.newDashboard) { 49 // Show new dashboard 50}

Database URL by Environment#

1// lib/db.ts 2import { PrismaClient } from '@prisma/client' 3 4function getDatabaseUrl(): string { 5 const url = process.env.DATABASE_URL 6 7 if (!url) { 8 throw new Error('DATABASE_URL is required') 9 } 10 11 // Add connection pooling for production 12 if (process.env.NODE_ENV === 'production') { 13 const poolUrl = new URL(url) 14 poolUrl.searchParams.set('connection_limit', '10') 15 poolUrl.searchParams.set('pool_timeout', '10') 16 return poolUrl.toString() 17 } 18 19 return url 20} 21 22const globalForPrisma = globalThis as unknown as { 23 prisma: PrismaClient | undefined 24} 25 26export const prisma = globalForPrisma.prisma ?? new PrismaClient({ 27 datasources: { 28 db: { url: getDatabaseUrl() } 29 }, 30 log: process.env.NODE_ENV === 'development' 31 ? ['query', 'error', 'warn'] 32 : ['error'] 33}) 34 35if (process.env.NODE_ENV !== 'production') { 36 globalForPrisma.prisma = prisma 37}

Vercel Environment Configuration#

1// vercel.json 2{ 3 "env": { 4 "NEXT_PUBLIC_APP_URL": "https://myapp.com" 5 }, 6 "build": { 7 "env": { 8 "DATABASE_URL": "@database-url-production" 9 } 10 } 11}

GitHub Environment Secrets#

1# .github/workflows/deploy.yml 2name: Deploy 3 4on: 5 push: 6 branches: [main, staging] 7 8jobs: 9 deploy: 10 runs-on: ubuntu-latest 11 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} 12 steps: 13 - uses: actions/checkout@v4 14 15 - name: Deploy 16 run: | 17 echo "Deploying to ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}" 18 env: 19 DATABASE_URL: ${{ secrets.DATABASE_URL }} 20 STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

Preview Environment Database#

1// lib/preview-db.ts 2export async function getPreviewDatabaseUrl(): Promise<string> { 3 const branchName = process.env.VERCEL_GIT_COMMIT_REF 4 5 if (!branchName || process.env.VERCEL_ENV !== 'preview') { 6 return process.env.DATABASE_URL! 7 } 8 9 // Use Neon or PlanetScale branch databases 10 const sanitizedBranch = branchName 11 .replace(/[^a-zA-Z0-9]/g, '-') 12 .toLowerCase() 13 .slice(0, 30) 14 15 // Create branch database if it doesn't exist 16 // This would call your database provider's API 17 const branchDbUrl = await createBranchDatabase(sanitizedBranch) 18 19 return branchDbUrl 20}

Usage Instructions#

  1. Create .env.example with all required variables
  2. Use Zod to validate environment variables at startup
  3. Create environment-specific configurations
  4. Set up feature flags per environment
  5. Configure CI/CD with environment-specific secrets

Best Practices#

  • Validate early - Check environment variables at startup
  • Never commit secrets - Use .env.local and CI secrets
  • Use typed config - Type-safe access to configuration
  • Environment parity - Keep environments as similar as possible
  • Preview databases - Use isolated databases for previews
  • Feature flags - Enable gradual rollouts
  • Document variables - Keep .env.example up to date