設定為什麼要從 code 裡分離出來

// ❌ 最常見的問題:設定寫死在 code 裡
const db = new Database({
  host: 'localhost',
  port: 5432,
  password: 'mypassword123',  // 這個進了 git
});

「等等我只是在本地開發」——git 的 blame 不會忘,repo 的歷史不會消失。即使你之後刪掉那行,任何人都能從 git log 裡找回那個密碼。

2024 年的 GitHub Advisory Database 裡,credential exposure 是 secret 洩漏的最常見來源,通常不是被駭,而是工程師無意間 commit 進去的。

12-factor app 的 Config 原則(2011 年提出,2026 年還是對的):

把應用的設定存在環境變數裡。設定 = 在不同部署環境(dev / staging / prod)之間會改變的東西。


什麼算是 Config

判斷方式:這個值在 dev / staging / prod 之間不一樣嗎? 是的話就是 config,不是的話是常數。

Config(環境變數):
  DATABASE_URL          # 每個環境的 DB 不同
  REDIS_URL             # dev 跑 local Redis,prod 跑 ElastiCache
  JWT_SECRET            # 每個環境用不同的 secret
  AWS_S3_BUCKET         # dev 和 prod 用不同 bucket
  LOG_LEVEL             # dev 是 debug,prod 是 warn
  MAX_UPLOAD_SIZE       # 可能依環境調整

常數(hardcode 在 code 裡沒問題):
  DEFAULT_PAGE_SIZE = 20
  PASSWORD_MIN_LENGTH = 8
  SUPPORTED_IMAGE_TYPES = ['jpg', 'png', 'webp']

型別安全的 Config

process.env.DATABASE_URL 的型別是 string | undefined——使用時要到處加 !??。更糟的是如果這個值不存在,應用通常不會在啟動時報錯,而是在某個 runtime 路徑走到才爆。

解法:在 startup 時驗證所有必要的 config,型別確定後注入。

TypeScript(Zod)

import { z } from 'zod';
 
const configSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('15m'),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  AWS_S3_BUCKET: z.string().optional(),
});
 
export type Config = z.infer<typeof configSchema>;
 
export function loadConfig(): Config {
  const result = configSchema.safeParse(process.env);
  if (!result.success) {
    console.error('Invalid config:');
    console.error(result.error.format());
    process.exit(1);  // 設定不合法直接停機,不要讓壞設定的 app 跑起來
  }
  return result.data;
}
 
// 在 main.ts / bootstrap 最早的地方呼叫
const config = loadConfig();

Python(pydantic-settings)

from pydantic_settings import BaseSettings
from pydantic import PostgresDsn, RedisDsn
 
class Config(BaseSettings):
    NODE_ENV: str = "development"
    PORT: int = 3000
    DATABASE_URL: PostgresDsn        # pydantic 自動驗 URL 格式
    JWT_SECRET: str
    JWT_EXPIRES_IN: str = "15m"
    REDIS_URL: RedisDsn | None = None
    LOG_LEVEL: str = "info"
 
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8"
    )
 
config = Config()  # 缺少必要欄位時啟動就報錯

Spring Boot(@ConfigurationProperties

# application.yml
app:
  jwt:
    secret: ${JWT_SECRET}    # 從環境變數取,不 hardcode
    expires-in: 15m
  database:
    pool-size: ${DB_POOL_SIZE:10}  # 有預設值的環境變數
@ConfigurationProperties(prefix = "app.jwt")
@Validated
public record JwtConfig(
    @NotBlank String secret,
    Duration expiresIn
) {}

多環境的 Config 分層

.env 檔案的分層慣例:

.env                 # 共用預設值(進 git,不放 secret)
.env.local           # 本地覆蓋(不進 git)
.env.development     # dev 環境
.env.staging         # staging 環境(通常不在本地,在 CI/CD 注入)
.env.production      # production(絕對不在本地,在 K8s Secret / Vault 注入)

.gitignore 要正確:

.env.local
.env.*.local
.env.staging
.env.production

進 git 的 .env 只放安全的預設值

# .env(進 git)
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
 
# 這些不進 git,每個工程師自己在 .env.local 設
# DATABASE_URL=
# JWT_SECRET=
# REDIS_URL=

Secret Management:不要存在 git

Secret(JWT_SECRET、DB 密碼、API key)的管理分幾個層次:

Level 1(最基本).env 檔案本地管理,不進 git

  • 問題:工程師之間怎麼傳密碼?Slack DM 或 1Password?新人 onboarding 麻煩。

Level 2:CI/CD 的 Secrets(GitHub Actions Secrets / GitLab CI Variables)

  • 在 CI/CD 介面手動設定 secret,Pipeline 以環境變數注入,不存在 repo

Level 3:K8s Secret

# kubectl create secret generic app-secrets
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  JWT_SECRET: "your-secret-here"
  DATABASE_URL: "postgres://..."
# Deployment 注入 secret 作為環境變數
env:
  - name: JWT_SECRET
    valueFrom:
      secretKeyRef:
        name: app-secrets
        key: JWT_SECRET

Level 4(生產級):HashiCorp Vault / AWS Secrets Manager / GCP Secret Manager

  • Secret 集中管理、有 audit log、支援 rotation
  • 應用啟動時動態拉 secret,不存在 env file 或 K8s Secret(因為 K8s Secret 預設只是 base64 編碼,不是加密)

實務建議:中小型團隊 Level 2–3 夠用;Level 4 在 PCI-DSS / SOC2 合規或 secret rotation 需求時才值得投入。


Config 的 Anti-patterns

// ❌ 在 module 層級直接讀 process.env(啟動時不驗證)
export const DB_URL = process.env.DATABASE_URL;  // undefined 也不報錯
 
// ❌ 環境判斷用 if/else 分散在 code 各處
if (process.env.NODE_ENV === 'production') {
  // prod 邏輯
} else {
  // dev 邏輯
}
// 這種 if 散在 30 個地方,改 config 結構很麻煩
 
// ❌ 同一個 secret 用在不同環境
JWT_SECRET=supersecretkey123  // dev 和 prod 用同一個
// 開發環境的 log 洩漏了 JWT,生產環境的 token 也能被 forge

各框架的 Config 整合

框架Config 工具Secret 整合
Expressdotenv + Zod 自建K8s Secret → 環境變數
FastAPIpydantic-settings(內建支援)K8s Secret → 環境變數
NestJS@nestjs/config(ConfigModule + Joi 驗證)K8s Secret → 環境變數
Spring Bootapplication.yml + @ConfigurationPropertiesSpring Cloud Config Server / Vault
Laravelconfig/ 目錄 + env() helperLaravel Forge / K8s

延伸閱讀