Panduan Lengkap Environment Variables Management
ID | EN

Panduan Lengkap Environment Variables Management

Minggu, 28 Des 2025

Pernah push API key ke GitHub? Atau aplikasi crash di production karena environment variable yang lupa di-set? Kamu nggak sendiri. Environment variables management adalah skill yang sering diabaikan, padahal bisa jadi pembeda antara aplikasi yang secure dan disaster yang menunggu terjadi.

Di artikel ini, kita bahas dari dasar sampai production-ready practices untuk mengelola environment variables.

Kenapa Environment Variables Penting?

Environment variables adalah cara untuk menyimpan konfigurasi yang berbeda di setiap environment (development, staging, production) tanpa hardcode di source code.

Bayangkan kamu hardcode seperti ini:

// ❌ JANGAN PERNAH LAKUKAN INI
const stripe = new Stripe('sk_live_xxx123secretkey');
const dbUrl = 'postgresql://admin:password123@prod-db.com:5432/myapp';

Masalahnya:

  • Security risk: Secret terekspos di Git history selamanya
  • Tidak fleksibel: Harus ganti code untuk beda environment
  • Susah di-rotate: Kalau key bocor, harus deploy ulang

Dengan environment variables:

// ✅ Cara yang benar
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const dbUrl = process.env.DATABASE_URL;

Struktur .env Files

Basic .env File

# .env
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev_api_key_12345
JWT_SECRET=super_secret_jwt_key

Multiple Environment Files

Best practice adalah punya beberapa file untuk environment berbeda:

project/
├── .env                 # Default values (committed, no secrets)
├── .env.local           # Local overrides (gitignored)
├── .env.development     # Development environment
├── .env.staging         # Staging environment
├── .env.production      # Production values (gitignored, atau di CI/CD)
└── .env.example         # Template untuk developer baru

.env.example Template

File ini di-commit ke repo sebagai dokumentasi:

# .env.example
# Copy file ini ke .env.local dan isi dengan values yang sesuai

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname

# Authentication
JWT_SECRET=your-jwt-secret-here
JWT_EXPIRES_IN=7d

# External APIs
STRIPE_SECRET_KEY=sk_test_xxx
SENDGRID_API_KEY=SG.xxx

# Feature Flags
ENABLE_NEW_DASHBOARD=false

Client vs Server Environment Variables

Ini sangat penting untuk framework seperti Next.js, Nuxt, atau SvelteKit.

Next.js Convention

# Server-side only (tidak terekspos ke browser)
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_xxx

# Client-side accessible (terekspos ke browser!)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
NEXT_PUBLIC_API_URL=https://api.example.com

Rule sederhana:

  • NEXT_PUBLIC_* = bisa diakses di browser (JANGAN taruh secrets!)
  • Tanpa prefix = hanya server-side

Kenapa Ini Penting?

// ❌ BAHAYA - secret terekspos ke browser
// Di component React
const apiKey = process.env.STRIPE_SECRET_KEY; // undefined di client

// ✅ AMAN - hanya di server
// Di API route atau getServerSideProps
export async function getServerSideProps() {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  // ...
}

Framework Lain

FrameworkClient PrefixServer Default
Next.jsNEXT_PUBLIC_No prefix
Nuxt 3NUXT_PUBLIC_NUXT_
ViteVITE_Tidak ada akses server
Create React AppREACT_APP_Semua terekspos!
SvelteKitPUBLIC_No prefix

Dotenv dan Environment Loading

Basic Setup dengan dotenv

npm install dotenv
// src/config.ts
import dotenv from 'dotenv';

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

// Atau load file spesifik
dotenv.config({ path: '.env.local' });

Loading Order (Next.js Style)

// Load berdasarkan NODE_ENV
import dotenv from 'dotenv';
import path from 'path';

const envFiles = [
  `.env.${process.env.NODE_ENV}.local`,
  `.env.local`,
  `.env.${process.env.NODE_ENV}`,
  '.env',
];

envFiles.forEach((file) => {
  dotenv.config({ path: path.resolve(process.cwd(), file) });
});

Environment Validation dengan Zod

Jangan percaya environment variables tanpa validasi. Lebih baik app crash saat startup daripada runtime error yang random.

Setup Zod Validation

npm install zod
// src/env.ts
import { z } from 'zod';

const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url(),
  
  // Server
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  
  // Authentication
  JWT_SECRET: z.string().min(32, 'JWT_SECRET harus minimal 32 karakter'),
  JWT_EXPIRES_IN: z.string().default('7d'),
  
  // External Services
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  
  // Optional with defaults
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  ENABLE_CACHE: z.coerce.boolean().default(true),
});

// Parse dan validate
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('❌ Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

Penggunaan

import { env } from './env';

// Type-safe dan validated!
console.log(env.DATABASE_URL); // string
console.log(env.PORT);         // number
console.log(env.ENABLE_CACHE); // boolean
npm install @t3-oss/env-nextjs zod
// src/env.mjs
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_API_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
});

Secrets Management untuk Production

Vercel

# Via CLI
vercel env add STRIPE_SECRET_KEY production
vercel env add DATABASE_URL production

# Pull env ke local
vercel env pull .env.local

Di Vercel Dashboard:

  • Settings → Environment Variables
  • Bisa set per environment (Production, Preview, Development)
  • Sensitive values otomatis encrypted

Railway

# Via CLI
railway variables set STRIPE_SECRET_KEY=sk_live_xxx

# Atau link ke service
railway link
railway variables

Railway punya fitur Reference Variables:

# Reference dari database service
DATABASE_URL=${{Postgres.DATABASE_URL}}

AWS (Parameter Store & Secrets Manager)

Parameter Store (untuk config biasa):

aws ssm put-parameter \
  --name "/myapp/production/API_KEY" \
  --value "secret_value" \
  --type SecureString
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

const ssm = new SSMClient({ region: "ap-southeast-1" });

async function getParameter(name: string) {
  const command = new GetParameterCommand({
    Name: name,
    WithDecryption: true,
  });
  const response = await ssm.send(command);
  return response.Parameter?.Value;
}

const apiKey = await getParameter("/myapp/production/API_KEY");

Secrets Manager (untuk secrets yang sering di-rotate):

import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

const client = new SecretsManagerClient({ region: "ap-southeast-1" });

async function getSecret(secretName: string) {
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  return JSON.parse(response.SecretString || '{}');
}

const dbCredentials = await getSecret("myapp/database");

HashiCorp Vault

Untuk enterprise-grade secrets management:

import Vault from 'node-vault';

const vault = Vault({
  apiVersion: 'v1',
  endpoint: 'https://vault.example.com:8200',
  token: process.env.VAULT_TOKEN,
});

const secret = await vault.read('secret/data/myapp/production');
const dbPassword = secret.data.data.DB_PASSWORD;

12-Factor App Principles

Dari 12factor.net, prinsip #3 tentang Config:

Store config in the environment

Apa yang Termasuk “Config”?

Harus di environment variables:

  • Database credentials
  • API keys dan secrets
  • External service URLs
  • Feature flags per environment

Bukan config (tetap di code):

  • Routes dan URL patterns
  • Dependency injection config
  • Internal business logic settings

Implementasi 12-Factor

// ✅ Good: Config dari environment
const config = {
  db: {
    url: process.env.DATABASE_URL,
    poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
  },
  redis: {
    url: process.env.REDIS_URL,
  },
  features: {
    newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
  },
};

// ❌ Bad: Hardcoded per environment
const config = {
  db: {
    url: process.env.NODE_ENV === 'production' 
      ? 'postgresql://prod...' 
      : 'postgresql://localhost...',
  },
};

Gitignore Best Practices

.gitignore untuk Environment Files

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

# Keep example file
!.env.example

# IDE
.idea/
.vscode/

# OS
.DS_Store

Pre-commit Hook untuk Cegah Secret Leak

Install git-secrets:

# macOS
brew install git-secrets

# Setup di repo
git secrets --install
git secrets --register-aws

Atau pakai gitleaks:

# Install
brew install gitleaks

# Scan repo
gitleaks detect --source . -v

GitHub Secret Scanning

Aktifkan di Settings → Code security → Secret scanning. GitHub akan alert kalau ada secret yang ke-push.

Development vs Production Environment

Separation of Concerns

// src/config/index.ts
import { z } from 'zod';

const baseSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
});

const developmentSchema = baseSchema.extend({
  DATABASE_URL: z.string().default('postgresql://localhost:5432/myapp_dev'),
  DEBUG: z.coerce.boolean().default(true),
});

const productionSchema = baseSchema.extend({
  DATABASE_URL: z.string().url(),
  DEBUG: z.literal(false).default(false),
  SENTRY_DSN: z.string().url(),
});

export const env = process.env.NODE_ENV === 'production'
  ? productionSchema.parse(process.env)
  : developmentSchema.parse(process.env);

Environment-Specific Behavior

// src/lib/logger.ts
import { env } from '../config';

export const logger = {
  debug: (...args: unknown[]) => {
    if (env.LOG_LEVEL === 'debug') {
      console.debug('[DEBUG]', ...args);
    }
  },
  info: (...args: unknown[]) => {
    console.info('[INFO]', ...args);
  },
  error: (...args: unknown[]) => {
    console.error('[ERROR]', ...args);
    
    // Kirim ke Sentry di production
    if (env.NODE_ENV === 'production') {
      // Sentry.captureException(args[0]);
    }
  },
};

Environment Variables di Docker

Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Jangan hardcode ENV di Dockerfile!
# ENV DATABASE_URL=xxx  ❌

# Gunakan ARG untuk build-time variables
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV

EXPOSE 3000
CMD ["node", "dist/index.js"]

Docker Compose

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - LOG_LEVEL=debug
    env_file:
      - .env.local
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${DB_USER:-postgres}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
      POSTGRES_DB: ${DB_NAME:-myapp}
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  postgres_data:

Docker Secrets (Swarm Mode)

# docker-compose.prod.yml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - stripe_key
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      STRIPE_KEY_FILE: /run/secrets/stripe_key

secrets:
  db_password:
    external: true
  stripe_key:
    external: true
// Read from file in production
import fs from 'fs';

function getSecret(envVar: string): string {
  const fileEnv = `${envVar}_FILE`;
  
  if (process.env[fileEnv]) {
    return fs.readFileSync(process.env[fileEnv], 'utf8').trim();
  }
  
  return process.env[envVar] || '';
}

const dbPassword = getSecret('DB_PASSWORD');

Common Mistakes dan Cara Menghindarinya

1. Commit .env ke Git

# Kalau sudah terlanjur commit
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Remove .env from tracking"

# Tapi secret sudah di history! Harus rotate semua keys.

Solusi: Pakai pre-commit hook dan secret scanning.

2. Tidak Validasi Environment Variables

// ❌ Crash di runtime
const port = parseInt(process.env.PORT); // NaN kalau undefined!

// ✅ Validate saat startup
const port = z.coerce.number().default(3000).parse(process.env.PORT);

3. Hardcode Conditional per Environment

// ❌ Susah maintain
const apiUrl = process.env.NODE_ENV === 'production'
  ? 'https://api.prod.com'
  : process.env.NODE_ENV === 'staging'
  ? 'https://api.staging.com'
  : 'http://localhost:3001';

// ✅ Satu source of truth
const apiUrl = process.env.API_URL;

4. Lupa Set di Production

// Checklist sebelum deploy
const requiredEnvs = [
  'DATABASE_URL',
  'JWT_SECRET', 
  'STRIPE_SECRET_KEY',
];

requiredEnvs.forEach((env) => {
  if (!process.env[env]) {
    throw new Error(`Missing required env: ${env}`);
  }
});

5. Expose Secret di Client-Side

// ❌ NEXT_PUBLIC_ berarti terekspos ke browser!
NEXT_PUBLIC_DATABASE_URL=xxx  // BAHAYA!
NEXT_PUBLIC_API_SECRET=xxx    // BAHAYA!

// ✅ Hanya public keys untuk client
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
NEXT_PUBLIC_ANALYTICS_ID=G-XXXXXXX

6. Tidak Rotate Secrets

Buat reminder untuk rotate secrets secara berkala:

  • API keys: setiap 90 hari
  • Database passwords: setiap 30-90 hari
  • JWT secrets: setiap 180 hari

7. Log Environment Variables

// ❌ JANGAN log secrets!
console.log('Config:', process.env);
console.log('DB URL:', process.env.DATABASE_URL);

// ✅ Mask sensitive values
const safeConfig = {
  NODE_ENV: process.env.NODE_ENV,
  DATABASE_URL: process.env.DATABASE_URL?.replace(/\/\/.*@/, '//***@'),
  API_KEY: process.env.API_KEY ? '***' : 'not set',
};
console.log('Config:', safeConfig);

Checklist Environment Variables

Sebelum deploy, pastikan:

  • Semua .env* files (kecuali .env.example) ada di .gitignore
  • Environment variables di-validate saat startup
  • Tidak ada hardcoded secrets di code
  • Client-side variables tidak mengandung secrets
  • Semua required env vars sudah di-set di production
  • Pre-commit hook untuk detect secrets
  • Secret scanning enabled di GitHub
  • Dokumentasi .env.example up to date
  • Secrets rotation schedule

Kesimpulan

Environment variables management bukan sekadar bikin file .env. Ini tentang:

  1. Security - Jangan pernah commit secrets
  2. Validation - Fail fast dengan Zod
  3. Separation - Client vs Server, Dev vs Prod
  4. Automation - Secret scanning dan pre-commit hooks
  5. Documentation - .env.example yang selalu update

Mulai dari yang simple: setup .gitignore yang benar, validasi dengan Zod, dan pakai managed secrets di production platform seperti Vercel atau Railway.

Kalau ada pertanyaan atau mau diskusi lebih lanjut, reach out di Twitter atau comment di bawah. Happy coding! 🔐