목차
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - GITHUB PUSH PROTECTION
remote: —— AWS Access Key ID ————————————————————————————
remote: locations:
remote: - commit: 7c4a9e2f1b3d8a5c6e9f0a1b2c3d4e5f6a7b8c9d
remote: path: services/worker/.env.local:4
remote: (?) To push, remove secret from commit(s) or follow URL to allow secret.
코드 작성 중 PR을 올리다 막혔다. .gitignore에 .env는 분명히 있었는데, 누군가 .env.local을 새로 만들면서 패턴이 빠졌다. 환경변수 보안 관리는 도구만 깔면 되는 줄 알았는데, 실제로는 사람이 새 파일을 만드는 순간마다 다시 깨지는 문제다. 프론트엔드만 하던 시절에는 NEXT_PUBLIC_ 프리픽스만 신경 쓰면 됐지만, 백엔드로 넘어온 뒤로는 비밀의 무게가 완전히 달라졌다. 오늘 우연히 발견한 것 몇 개를 정리한다.
오늘 한 것 — .env 누수 경로 다시 그리기
이처럼, GitHub Push Protection이 막아준 덕분에 사고는 안 났다. 정작 무서운 건 막힌 지점이 아니라, 막히지 않고 통과했을 수도 있는 경로들이다. 프론트엔드에서는 .env가 빌드 타임에 번들에 박혀 브라우저로 나가는 문제가 컸다. 백엔드는 반대다. 파일은 서버에 남고, 그 서버에 누가 들어왔는지가 핵심이다. 같은 .env인데 위협 모델이 완전히 다르다.
.gitignore로 막을 수 없는 케이스
오늘 정리한 누수 경로는 7가지였다. Git 커밋이 가장 유명하지만, 실무에서 더 자주 새는 건 그 외 경로다.
| 경로 | 잘 알려진 정도 | 실제 빈도 | 1차 방어 |
|---|---|---|---|
Git 커밋 (.env add) |
매우 높음 | 중간 | gitignore + Push Protection |
| Docker 이미지 레이어 | 중간 | 높음 | --secret 마운트 |
| CI 로그 출력 | 낮음 | 매우 높음 | ::add-mask::, set-output 금지 |
백업 파일 (.env.bak) |
낮음 | 높음 | gitignore 패턴 확장 |
| 에디터 임시 파일 | 매우 낮음 | 중간 | .env* 패턴 |
컨테이너 inspect |
낮음 | 중간 | 런타임 주입, 이미지 미포함 |
| 클립보드/스크린샷 | – | 의외로 많음 | 마스킹 도구, 검토 습관 |
특히 Docker 레이어는 프론트 시절에 한 번도 신경 쓴 적이 없었다. COPY .env . 한 줄이면 이미지 안에 영구히 박힌다. docker history로 누구나 볼 수 있다. 멀티스테이지 빌드를 써도 마지막 스테이지에 같은 짓을 하면 의미가 없다.
.gitignore 패턴 다시 짜기
따라서, 흔히 보는 .env만으로는 부족하다. 오늘 팀 레포에 적용한 패턴이다.
# secrets
.env
.env.*
!.env.example
!.env.template
# 백업/임시
*.env.bak
*.env.old
*.env.local
*.env.*.local
# 에디터 스왑
.env.swp
.env~
# 시크릿 디렉토리
/secrets/
/.secrets/
!.env.example로 예외를 두는 이유는, 신규 입사자가 어떤 키가 필요한지 알 수 있어야 하기 때문이다. 값은 xxxxx로 두되 키 이름은 노출한다. 키 이름 자체가 비밀인 경우는 거의 없다(다만 내부 서비스 도메인이 키 이름에 박혀 있다면 그건 별도로 마스킹한다).
새로 알게 된 것 — 환경변수 보안 관리의 단계별 현실
특히, 오늘 가장 크게 바뀐 인식은, "환경변수 보안 관리"가 단일한 한 가지 기법이 아니라는 점이다. 팀의 규모와 비밀의 민감도에 따라 단계가 있다. 프론트엔드 시절에는 그냥 Vercel 대시보드에 박아 넣으면 끝이었는데, 백엔드 멀티 서비스 환경에서는 그 한 단계로는 안 됐다.
4단계 분류
지금까지 거쳐온 단계를 정리해보면 이렇다.
| 단계 | 저장 위치 | 적합한 규모 | 약점 |
|---|---|---|---|
1. 로컬 .env 파일 |
개발자 노트북 | 1인 사이드 프로젝트 | 동기화·유출 |
| 2. 플랫폼 환경변수 | Vercel/Heroku 콘솔 | 5인 이하 단일 서비스 | 감사 추적 미흡 |
| 3. CI 시크릿 + 런타임 주입 | GitHub Actions + 서버 env | 10인 이하 멀티 서비스 | 회전 자동화 어려움 |
| 4. 전용 시크릿 매니저 | AWS Secrets Manager, Vault | 그 이상, 또는 규제 산업 | 운영 부담 |
또한, 오늘 발견한 것: 많은 팀이 2단계에 머무르면서 4단계가 필요하다고 착각한다. 반대도 많다. 5명 짜리 스타트업이 Vault를 깔아놓고 정작 키 회전은 1년에 한 번도 안 한다. 도구를 도입해도 운영 루틴이 없으면 1단계와 별 차이가 없다.
회전(Rotation)이 진짜 차이
게다가, 1단계와 4단계의 결정적 차이는 암호화도 접근 제어도 아니다. 회전이다. .env 파일에 박힌 API 키는 사실상 영원히 안 바뀐다. 누가 한 번 보면 그 키는 영원히 그 사람 손에 있다고 봐야 한다. Secrets Manager나 Vault는 회전 자체가 기능이다.
# AWS Secrets Manager — Python boto3 예시
import boto3
import json
from functools import lru_cache
# 캐시: 매 요청마다 호출하면 비용·레이턴시 모두 손해
@lru_cache(maxsize=32)
def get_secret(secret_name: str, region: str = "ap-northeast-2") -> dict:
client = boto3.client("secretsmanager", region_name=region)
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response["SecretString"])
# 사용
db_creds = get_secret("prod/db/main")
conn = psycopg2.connect(
host=db_creds["host"],
user=db_creds["username"],
password=db_creds["password"],
)
게다가, 회전 주기를 30일로 설정하면 Secrets Manager가 자동으로 RDS 비밀번호를 바꿔준다. 애플리케이션은 캐시 TTL이 만료된 다음 호출에서 새 값을 가져온다. 처음 봤을 때 "이게 왜 기본이 아니지" 싶었다.
Vault는 언제 쓰나
예를 들어, AWS만 쓰는 팀이라면 Secrets Manager만으로 충분한 경우가 많다. Vault를 고려할 시점은 보통 세 가지다.
첫째, 멀티 클라우드 환경. AWS + GCP + 온프레미스가 섞이면 Vault가 단일 진실 공급원 역할을 한다. 둘째, 동적 시크릿이 필요한 경우. Vault는 요청 시점에 짧은 수명의 DB 자격증명을 생성한다. TTL이 1시간이면, 유출돼도 1시간 뒤 자동 무효화된다. 셋째, 규제. PCI-DSS, HIPAA 같은 감사가 들어오는 환경.
# Vault dynamic database secrets — 설정 예
path "database/creds/readonly" {
capabilities = ["read"]
}
# 클라이언트가 호출하면 매번 새 계정 생성
# {"username": "v-token-readonly-x7k2", "password": "...", "lease_duration": 3600}
Vault는 운영 부담이 결코 작지 않다. HA 클러스터, unseal 절차, audit 로그 보관까지 손이 많이 간다. 5인 팀이 도입하면 Vault 운영자가 따로 필요할 정도다.
코드 — 실전 적용 3종 세트
예를 들어, 오늘 실제 레포에 머지한 변경 세 가지를 그대로 옮긴다. 모두 환경변수 보안 관리의 가장 흔한 구멍을 막는다.
1. pre-commit으로 시크릿 자동 차단
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
gitleaks는 정규식 기반으로 AWS 키, Stripe 키, GitHub 토큰 같은 알려진 패턴을 잡는다. detect-secrets는 엔트로피 기반이라 무작위 문자열도 탐지한다. 둘 다 깔면 false positive가 좀 늘지만, baseline 파일로 한 번 정리하면 그 뒤로는 새 시크릿만 잡힌다. (출처: gitleaks v8.18.4 release)
2. Docker 빌드에서 --secret 마운트
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
# BuildKit secret mount — 이미지 레이어에 남지 않는다
RUN --mount=type=secret,id=pip_token \
PIP_TOKEN=$(cat /run/secrets/pip_token) \
pip install --index-url https://__token__:${PIP_TOKEN}@pypi.internal/simple/ -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# 런타임 시크릿은 ENV로 박지 않는다. 컨테이너 실행 시 주입.
그런데, 빌드 명령은 이렇게 호출한다.
# 빌드 시 시크릿을 마운트로만 전달
DOCKER_BUILDKIT=1 docker build \
--secret id=pip_token,src=$HOME/.pypi_token \
-t myapp:latest .
# 이미지 안에 시크릿이 없는지 확인
docker history myapp:latest --no-trunc | grep -i token
# (아무것도 안 나와야 정상)
처음에 ARG로 토큰을 받았다가 이미지 안에 그대로 박혀버린 적이 있다. ARG는 빌드 인자라서 안전한 줄 알았는데, RUN echo $ARG가 한 줄이라도 있으면 그 레이어에 평문으로 남는다. --secret은 마운트 형태라서 빌드가 끝나면 사라진다.
3. GitHub Actions에서 OIDC로 키 없이 AWS 접근
# .github/workflows/deploy.yml
permissions:
id-token: write # OIDC 토큰 발급에 필요
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-northeast-2
# access key 없음. OIDC로 일시 자격증명 받는다.
- name: 시크릿 조회 후 배포
run: |
# GitHub Secrets에 AWS 키를 안 박아도 된다
aws secretsmanager get-secret-value \
--secret-id prod/api/keys \
--query SecretString --output text > /tmp/secrets.json
또한, 이 패턴을 적용한 뒤로 GitHub Secrets에서 AWS_ACCESS_KEY_ID를 완전히 지웠다. 키 회전 작업이 통째로 사라졌다. (출처: aws-actions/configure-aws-credentials v4 README)
메모 — 새로 추가한 검토 체크리스트
오늘 정리하면서 팀 위키에 박은 항목들이다. PR 리뷰할 때 한 번씩 보는 용도.
.env*패턴이.gitignore에 있는지, 예외(!.env.example)가 명시적인지- 새로 추가된 Dockerfile에
COPY .env나ENV SECRET=라인이 없는지 docker history <image> --no-trunc로 마지막 빌드 검사- CI 워크플로우의
run:블록에서 시크릿을echo하거나cat하지 않는지 - 운영 비밀번호가 90일 이상 회전되지 않았는지 (Secrets Manager 콘솔 확인)
- 신규 입사자에게
.env.example만 공유하고 실제 값은 Secrets Manager 경로를 알려주는지
반면, 이전 글에서 본 사람도 있을 텐데, Docker BuildKit 공식 문서와 HashiCorp Vault Dynamic Secrets 가이드는 한 번씩 읽어볼 만하다. 2026년 6월 기준으로 둘 다 안정 버전 문서가 최신화되어 있다.
그러나, 오늘의 액션 아이템 세 개만 남긴다. 첫째, 지금 당장 gitleaks를 pre-commit에 추가해라. 5분이면 된다. 둘째, 운영 환경에서 마지막으로 회전한 시크릿이 언제인지 한 번 확인해라. 셋째, Dockerfile에 COPY .env나 ARG 기반 토큰 전달이 있는지 grep 한 번 돌려라.
예를 들어, 개인적으로는 작은 팀일수록 Vault보다 AWS Secrets Manager + OIDC 조합이 비용 대비 더 나은 것 같다.
관련 글
- Let’s Encrypt Nginx HTTPS 설정 완전 가이드: Certbot·Docker·Reverse Proxy 자동 갱신 – Let’s Encrypt를 Nginx에 붙일 때 발급 도구·인증 방식·배포 구조에 따라 갱신 안정성이 크게 갈린다. 실무에서 자주 마주치는…
- GitHub Actions 워크플로우 권한 설정 실전 – permissions 최소화 가이드 – GitHub Actions의 GITHUB_TOKEN 기본 권한은 의외로 넓다. write-all로 시작해 줄이라는 조언은 사실상 줄이는 시…
- 쿠버네티스 컨테이너 이미지 취약점 스캔: Trivy vs Snyk vs Grype 비교 – 쿠버네티스 컨테이너 이미지 취약점 스캔 도구 3종을 후보로 놓고 비교했다. 속도, 오탐, CI 통합 난이도를 기준으로 따져본 내용을 모았다.