goodchuck 블로그

.env 파일을 S3 에 보관하기 — 200줄 bash 로 만든 env 금고

8분 읽기

개인 모노레포에 앱이 하나씩 늘면서 .env 파일도 앱마다 늘었어요. .env 는 git 에 올리지 않으니, 이 파일들은 맥북 한 대에만 있었습니다. 맥북을 잃어버리거나 다른 PC 에서 작업하려고 하면, 저장소를 clone 해도 앱을 띄울 수가 없는 상태였죠.

그래서 .env 를 S3 버킷에 보관하고 받아오는 작은 도구를 만들었어요. bash 스크립트 하나(약 200줄)에 pnpm vault 라는 이름을 붙였습니다.

pnpm vault status                    # 전체 동기화 상태
pnpm vault diff lol-watch            # 키 단위 차이 (값은 안 찍음)
pnpm vault pull lol-watch            # 금고 → 로컬
pnpm vault push lol-watch production # 로컬 → 금고 (차이를 보여 주고 확인)
pnpm vault pull --all                # 새 PC: 전부 받기

다만 도구보다 먼저 한 일이 있어요. .env 에 뭘 넣을지부터 정리했습니다.

1. 먼저 값을 나눈다 — 비밀인가, 아닌가

예전 .env 엔 API 키 옆에 포트, 폴링 간격, 리전 같은 값이 같이 들어 있었어요. 이걸 그대로 금고에 올리면 금고가 "설정 전부"를 들고 있게 되고, 무엇이 진짜 비밀인지 흐려져요.

기준은 하나예요. 유출되면 남이 쓸 수 있는가.

분류두는 곳예
비밀.env → S3 금고API 키, 토큰, 웹훅 URL, 비밀번호 든 DB 주소
비밀 아님코드 기본값 (커밋)포트, 경로, 리전, 폴링 간격
비밀 아님 + 운영만 다름배포 정의 (compose.prod.yml 등)APP_ENV, 컨테이너 안 DB 경로

비밀 아닌 값을 .env 에 넣지 않는 데엔 이유가 하나 더 있어요. .env 에 POLL_INTERVAL_SEC=300 이 있으면, 나중에 코드 기본값을 120 으로 바꿔도 .env 의 300 이 계속 이겨요. 코드를 고쳤는데 동작이 안 바뀌는 거죠. 그래서 비밀 아닌 값은 코드에 기본값으로 두고, 그게 정본이 되게 했어요.

class Config:
    riot_api_key: str | None = os.getenv("RIOT_API_KEY")          # 비밀 — 기본값 없음
    riot_platform: str = os.getenv("RIOT_PLATFORM", "kr")         # 비밀 아님 — 코드가 정본
    poll_interval_sec: int = int(os.getenv("POLL_INTERVAL_SEC", "300"))
    port: int = int(os.getenv("PORT", "4080"))

이렇게 나누고 나니 처음 적용한 앱(lol-watch)의 .env 에는 비밀값 두 개만 남았어요.

비밀값엔 기본값을 두지 않는다

비밀값은 없을 때 어떻게 할지를 정해요. 운영에서 꼭 필요한 값이면 시작을 실패시키고, 선택 기능이면 그 기능만 꺼요.

if not config.riot_api_key:
    if config.app_env == "production":
        raise RuntimeError("RIOT_API_KEY 미설정 — 운영에선 폴링이 전부 멈춘다")
    logger.warning("RIOT_API_KEY 미설정 — 폴링 없이 API 만 뜬다")

2. 우선순위 — 오른쪽이 이긴다

코드 기본값  <  .env (금고 사본)  <  .env.local (내 PC 전용)  <  실제 환경변수

.env.local 은 "잠깐 바꿔 보기"용이에요. 금고에 올라가지 않아서, 여기에 임시 값을 넣어도 다른 곳에 퍼지지 않아요.

python-dotenv 의 load_dotenv 는 이미 있는 키를 덮지 않아요. 그래서 이긴 쪽을 먼저 읽으면 돼요. 경로는 __file__ 기준으로 잡아서, 어느 폴더에서 실행하든 같은 파일을 읽게 했고요.

_APP_ROOT = Path(__file__).resolve().parents[2]

# load_dotenv 는 이미 있는 키를 덮지 않는다 → 먼저 읽은 쪽이 이긴다.
load_dotenv(_APP_ROOT / ".env.local")
load_dotenv(_APP_ROOT / ".env")

계층이 넷이면 "이 값이 어디서 왔지?"가 헷갈려요. 그래서 앱이 시작할 때 키마다 출처를 로그로 남겨요(값은 찍지 않아요). .env.local 이 무언가를 덮고 있으면 경고도 하고요.

config 출처: {'RIOT_API_KEY': '.env', 'DISCORD_WEBHOOK_URL': '.env'}

중요한 점 하나 — 앱은 S3 를 읽지 않아요. 실행할 때 S3 를 읽게 하면 모든 환경에 AWS 자격증명과 네트워크가 필요해져요. S3 는 실행 계층이 아니라 .env 의 원본 보관소일 뿐이에요.

3. 금고 — S3 버킷 하나

버킷은 이 용도로만 하나 만들었고, 설정은 이렇게 했어요.

설정왜
퍼블릭 액세스 차단당연히
버전 관리 켜기잘못 덮어써도 이전 버전으로 되살린다
서버 측 암호화(SSE)저장된 파일 암호화
HTTPS 강제(버킷 정책)평문 전송 거부

파일은 앱과 단계로 나눠 둬요.

apps/<앱>/local.env       ↔  apps/<앱>/.env
apps/<앱>/production.env  ↔  apps/<앱>/.env.production

4. diff 는 키 이름만 보여 준다

push 하기 전에 "뭐가 바뀌는지"는 봐야 하는데, 터미널에 비밀값이 찍히면 안 돼요(화면 공유, 터미널 기록, AI 도구 로그…). 그래서 diff 는 키 이름과 변화 종류만 보여 줘요.

── lol-watch/production 올릴 변경
  + SLACK_WEBHOOK_URL  (로컬에만)
  ~ RIOT_API_KEY  (값 다름)

구현은 awk 로 두 파일을 키=값 으로 읽어 키 집합을 비교하는 정도예요. export 로 시작하는 줄과 주석·빈 줄은 건너뛰고요.

5. 덮어쓰기 막기 — 낙관적 잠금

맥북과 다른 PC 두 곳에서 쓰면 이런 일이 생길 수 있어요.

  • A 에서 키를 바꾸고 push → B 에서 예전 파일로 push 하면 A 의 변경이 사라진다
  • 로컬에서 고치고 push 를 잊은 채 pull 하면 내 변경이 사라진다

그래서 마지막으로 동기화했을 때의 S3 버전 ID 와 파일 해시 를 앱 폴더의 .env.vault-state(gitignore)에 적어 둬요.

# <stage> <S3 versionId> <sha256>   (값은 예시)
production 3HL4kqtJlcpXroDTDmJ.rmSpXd3dIbrHY 9f2c…
명령확인하는 것걸리면
pull로컬 파일 해시가 마지막 동기화 때와 같은가"push 안 한 변경이 있어요" — 거부
push금고의 현재 버전 ID 가 마지막 동기화 때와 같은가"그 사이 금고가 바뀌었어요" — 거부

거부될 때도 키 단위 diff 를 같이 보여 줘서 뭘 합쳐야 하는지 알 수 있어요. 정말 덮어써야 하면 --force 를 붙이고, 그래도 S3 버전 관리 덕분에 덮인 값은 되살릴 수 있어요.

status 는 이 두 값을 조합해 상태를 알려 줘요.

  lol-watch        local       동기화됨
  lol-watch        production  로컬 수정됨 — push 필요

6. 흐름은 한 방향 — 로컬 → 금고 → 서버

로컬 .env.production ──push──▶ S3 금고 ──(make setup-env-prod 가 pull)──▶ EC2 .env

EC2 에 올리는 명령은 올리기 전에 금고에서 먼저 pull 해요. 로컬에 push 안 한 변경이 있으면 pull 이 거부하니, 서버엔 항상 금고와 같은 값만 올라가요.

setup-env-prod: ## S3 금고 → .env.production → EC2 의 .env
	@bash ../../scripts/env-vault.sh pull lol-watch production
	@scp -i $(KEY_FILE) .env.production $(EC2_USER)@$(EC2_IP):$(RELEASE_ROOT)/.env

그래서 규칙도 하나 생겼어요. EC2 의 .env 를 직접 고치지 않는다 — 다음에 올릴 때 금고 값으로 덮이니까요.

새 PC 에선 이렇게 끝나요.

git clone …
aws configure --profile <프로필>
pnpm vault pull --all

다른 방법과 비교

비밀값을 보관하는 방법은 많아요. 지금 규모(혼자 쓰는 개인 모노레포, 서버 한 대)에서 고른 이유를 표로 정리하면 이래요.

방법장점이 경우에 안 맞았던 점
비밀번호 관리자에 메모로따로 만들 게 없다앱·단계가 늘면 복붙 실수, 차이 비교가 안 된다
git 에 암호화해서 커밋 (sops, git-crypt)코드와 같이 버전 관리암호화 키를 또 어딘가 보관해야 하고, 실수로 평문 커밋하면 기록에 남는다
AWS SSM Parameter Store · Secrets Manager앱이 실행 때 직접 읽을 수 있다앱마다 SDK·권한이 필요해진다 — 로컬 개발까지 AWS 에 묶인다
비밀값 관리 서비스 (Doppler 등)팀 공유·감사 기록까지혼자 쓰기엔 서비스가 하나 더 늘어난다
S3 + bash 스크립트앱은 그냥 .env 를 읽는다, 버전 관리 공짜직접 만든 도구라 기능이 딱 필요한 만큼뿐

결국 "앱 코드는 .env 만 알고, 보관과 동기화는 밖에서"가 제일 단순했어요.

대가

  • AWS 자격증명이 있어야 pull/push 할 수 있어요. 새 PC 에선 aws configure 가 첫 단계예요.
  • 혼자 쓰는 걸 전제로 만들었어요. 팀이라면 권한 분리나 감사 기록이 필요할 텐데, 이 스크립트엔 없어요.
  • 잠금은 "마지막 동기화 이후 바뀌었나"만 봐요. 두 곳에서 정말 동시에 push 하는 경우까지 막지는 않아요(혼자 쓰니 감수).
  • 값 분류를 앱마다 한 번씩 손봐야 해요. 그래서 적용 순서를 정해 뒀어요 — 정리 전 원본을 먼저 금고에 push 해 첫 버전으로 남기고, 정리본은 그다음에 올린다.

.env 가 맥북 한 대에만 있던 상태에서, 지금은 clone 하고 명령 하나면 어느 PC 에서든 앱을 띄울 수 있어요.