비밀을 다시는 커밋하지 마세요: AWS Secrets Manager에서 .env 파일 생성

발행: (2025년 12월 13일 오전 02:49 GMT+9)
5 min read
원문: Dev.to

Source: Dev.to

TL;DR

AWS Secrets Manager에 비밀을 저장합니다. Python 스크립트로 필요할 때마다 .env 파일을 생성합니다. 이제 자격 증명을 절대 커밋하지 마세요.

The Problem

모든 팀은 결국 비밀을 커밋하게 됩니다. GitHub은 지난해 비밀 스캔을 통해 1,200만 개 이상의 노출된 자격 증명을 감지했습니다.

일반적인 접근 방식은 모두 실패 가능성이 있습니다:

  • **.gitignore**는 개발자가 추가하는 것을 잊거나, 새로 클론한 뒤 Slack으로 파일을 요청할 때 실패합니다.
  • SOPS 암호화는 여전히 파일을 git에 두고, 키 관리 오버헤드를 추가하며, 병합 충돌 악몽을 만들 수 있습니다.
  • .env.example 템플릿은 오래되고 수동 복사가 필요합니다.

우리는 더 나은 것이 필요했습니다: 레포지토리 밖에 존재하는 비밀과 마찰 없는 개발자 경험.

The Solution

비밀은 AWS Secrets Manager에 보관됩니다. 개발자는 한 번의 명령으로 .env 파일을 생성합니다:

make env
# .env가 로컬에 생성되고 바로 사용할 수 있습니다

이 파일은 .gitignore에 포함됩니다. 버전 관리와는 전혀 접촉하지 않으며, AWS에서 비밀이 변경되면 개발자는 다시 생성해 최신 값을 얻습니다.

Implementation

1. Organize Secrets in AWS

애플리케이션과 환경별로 비밀을 구조화합니다:

/myapp/dev/database   → {"DB_HOST": "...", "DB_PASSWORD": "..."}
/myapp/dev/api-keys   → {"STRIPE_KEY": "...", "SENDGRID_KEY": "..."}
/myapp/prod/database  → {"DB_HOST": "...", "DB_PASSWORD": "..."}
/myapp/prod/api-keys  → {"STRIPE_KEY": "...", "SENDGRID_KEY": "..."}

AWS CLI를 사용해 비밀을 생성합니다:

aws secretsmanager create-secret \
  --name /myapp/dev/database \
  --secret-string '{"DB_HOST":"localhost","DB_PASSWORD":"devpass123"}'

2. The Python Script

generate_env.py는 Secrets Manager에서 .env 파일을 생성합니다:

#!/usr/bin/env python3
"""
Generate .env file from AWS Secrets Manager.

Usage:
    python generate_env.py dev
    python generate_env.py prod --force
"""

import argparse
import json
import os
import sys
from pathlib import Path

import boto3
from botocore.exceptions import ClientError, NoCredentialsError

# Configuration
APP_NAME = "myapp"
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
ENV_FILE = ".env"

SECRET_KEYS = ["database", "api-keys", "third-party"]

def get_secret(secret_name: str, region: str) -> dict:
    """Fetch a secret from AWS Secrets Manager."""
    client = boto3.client("secretsmanager", region_name=region)

    try:
        response = client.get_secret_value(SecretId=secret_name)
        return json.loads(response.get("SecretString", "{}"))
    except ClientError as e:
        if e.response["Error"]["Code"] == "ResourceNotFoundException":
            print(f"  Warning: Secret '{secret_name}' not found")
            return {}
        raise

def validate_aws_credentials() -> bool:
    """Check if AWS credentials are configured."""
    try:
        sts = boto3.client("sts")
        identity = sts.get_caller_identity()
        print(f"Authenticated as: {identity['Arn']}")
        return True
    except NoCredentialsError:
        print("Error: AWS credentials not found.")
        print("\nFix with one of:")
        print("  1. aws configure")
        print("  2. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY")
        print("  3. Use IAM role (if on AWS)")
        return False

def fetch_all_secrets(environment: str, region: str) -> dict:
    """Fetch all secrets for the environment."""
    all_secrets = {}
    for key in SECRET_KEYS:
        secret_path = f"/{APP_NAME}/{environment}/{key}"
        print(f"  Fetching: {secret_path}")
        all_secrets.update(get_secret(secret_path, region))
    return all_secrets

def generate_env_content(secrets: dict) -> str:
    """Generate .env content from secrets."""
    lines = [
        "# Auto-generated from AWS Secrets Manager",
        "# DO NOT COMMIT THIS FILE",
        "",
    ]
    for key, value in sorted(secrets.items()):
        if isinstance(value, str) and " " in value:
            value = f'"{value}"'
        lines.append(f"{key}={value}")
    return "\n".join(lines) + "\n"

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("environment", choices=["dev", "staging", "prod"])
    parser.add_argument("-f", "--force", action="store_true")
    parser.add_argument("-o", "--output", default=ENV_FILE)
    args = parser.parse_args()

    print(f"Generating .env for '{args.environment}'\n")

    if not validate_aws_credentials():
        sys.exit(1)

    secrets = fetch_all_secrets(args.environment, AWS_REGION)

    if not secrets:
        print(f"\nError: No secrets found at /{APP_NAME}/{args.environment}/*")
        sys.exit(1)

    print(f"\nFound {len(secrets)} secret values")

    content = generate_env_content(secrets)
    path = Path(args.output)

    if path.exists() and not args.force:
        if input(f"{args.output} exists. Overwrite? [y/N]: ").lower() != "y":
            sys.exit(0)

    path.write_text(content)
    print(f"Generated: {args.output}")

if __name__ == "__main__":
    main()

3. Shell Wrapper and Makefile

Shell wrapper (generate-env.sh):

#!/bin/bash
# generate-env.sh
set -e
ENV=${1:-dev}

if ! python3 -c "import boto3" 2>/dev/null; then
    pip3 install boto3 --quiet
fi

python3 "$(dirname "$0")/generate_env.py" "$ENV" "${@:2}"

Makefile targets:

.PHONY: env env-dev env-prod env-dry

env:
	@./scripts/generate-env.sh dev

env-dev:
	@./scripts/generate-env.sh dev

env-prod:
	@./scripts/generate-env.sh prod

env-dry:
	@./scripts/generate-env.sh dev --dry-run

4. GitHub Actions with OIDC

자격 증명을 저장할 필요가 없습니다. OIDC를 사용해 AWS 역할을 가정합니다:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions
          aws-region: us-east-1

      - name: Generate .env
        run: |
          pip install boto3
          python scripts/generate_env.py prod --force

      - name: Deploy
        run: |
          # Your deployment commands
          echo "Deploying..."

      - name: Cleanup
        if: always()
        run: rm -f .env

5. GitLab CI

GitLab OIDC와 동일한 패턴:

deploy:
  stage: deploy
  image: python:3.11-slim
  script:
    - pip install boto3
    - |
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn ${AWS_ROLE_ARN}
      --role-session-name "gitlab-${CI_PIPELINE_ID}"
      --web-identity-token ${CI_JOB_JWT_V2}
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
    - python scripts/generate_env.py prod --force
    - echo "Deploying..."
  after_script:
    - rm -f .env

6. IAM Permissions

개발자는 관련 비밀을 읽을 수 있는 정책이 필요합니다. 예시:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": [
        "arn:aws:secretsmanager:*:*:secret:/myapp/*"
      ]
    }
  ]
}
Back to Blog

관련 글

더 보기 »

AWS Terraform IAM 사용자 관리

소개: AWS에서 IAM 사용자를 수동으로 관리하면 복잡해지고 오류가 발생하기 쉬우며 확장이 어려워집니다. 팀이 성장함에 따라 반복 가능하고 감사 가능한…

Day 16. IAM 사용자 생성

!Day 16 커버 이미지. IAM 사용자 생성 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads...