Kamal 비밀을 AWS Secrets Manager에 저장하고 저렴한 Hetzner VPS에 배포
출처: Dev.to
Kamal을 사용하면서 문제가 발생했습니다. 제 .kamal/secrets 파일에 API 키들이 평문으로 저장돼 있었고, 내 노트북에 그대로 있었습니다. 접근 권한이 있는 사람이라면 누구든지 읽을 수 있었습니다.
TL;DR: Kamal을 AWS Secrets Manager와 함께 사용하고 Hetzner VPS에 배포하세요. 평문 비밀이 없고, 저렴한 호스팅에 규정 준수도 만족합니다.
문제점
Kamal은 애플리케이션 배포에 아주 좋습니다. 하지만 기본적으로 비밀이 평문 파일에 저장됩니다. SOC 2와 GDPR 같은 규정에는 맞지 않죠. 관리형 비밀 저장소가 필요합니다. 저는 AWS Secrets Manager를 선택했습니다.
하지만 또 다른 문제가 생겼습니다. kamal secrets fetch --adapter aws_secrets_manager 명령에 --from 옵션을 사용할 때는 각 키가 별개의 AWS 비밀이어야 합니다. 모든 값을 하나의 JSON 블롭으로 저장하면 다음과 같은 오류가 발생합니다.
ERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret.
Hetzner VPS 준비
Hetzner CAX 시리즈는 월 4유로 정도부터 시작합니다. 저는 2 vCPU와 4 GB RAM을 갖춘 CX22를 사용합니다. 프로덕션 용도로 충분합니다.
# Hetzner 서버에서
apt update && apt install -y docker.io
# Kamal이 연결할 수 있도록 SSH 키 복사
ssh-copy-id root@your-server-ip
config/deploy.yml 예시
servers:
web:
hosts:
- runtime.yourdomain.com
proxy:
ssl: true
hosts:
- runtime.yourdomain.com
healthcheck:
path: /health/ready
registry:
server: docker.io
username: your-docker-user
password:
- KAMAL_REGISTRY_PASSWORD
KAMAL_REGISTRY_PASSWORD를 위해 Docker Hub 계정과 개인 액세스 토큰이 필요합니다.
AWS Secrets Manager에 비밀 저장
- Secrets Manager → Store a new secret 로 이동
- “Other type of secret” 선택
- Plaintext 탭으로 전환하고 아래 JSON을 붙여넣기
{
"DEEPGRAM_API_KEY": "your_deepgram_key",
"ASSEMBLY_AI_API_KEY": "your_assemblyai_key",
"REDIS_URL": "redis://:password@your-redis:6379",
"KAMAL_REGISTRY_PASSWORD": "your_docker_token"
}
- 이름을
myapp/production/secrets로 지정 - Store 클릭
- 서버와 가장 가까운 리전을 선택 (예: 독일에 있으면
eu-central-1(프랑크푸르트)). 지연 시간을 낮추고 GDPR도 만족합니다.
배포 머신에 권한 부여
- IAM → Users → Create user
- 사용자 이름을
kamal-deploy로 지정 - 콘솔 접근은 해제하고 CLI only 로 설정
secrets-manager라는 그룹을 만들고SecretsManagerReadWrite정책을 연결- 아래 인라인 정책을 추가 (Batch 읽기용)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:BatchGetSecretValue",
"secretsmanager:ListSecrets"
],
"Resource": "*"
}
]
}
- 사용자를 방금 만든 그룹에 추가
- IAM 정책이 전파되기까지는 약 1분 정도 걸릴 수 있습니다. 처음 실패하면 30초 정도 기다렸다가 다시 시도하세요.
AWS CLI 설정
aws configure
# AWS Access Key ID: IAM 사용자에서 복사
# AWS Secret Access Key: IAM 사용자에서 복사
# Default region name: eu-central-1
# Default output format: json
테스트
aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | head -c 50
JSON 앞부분이 출력되면 정상입니다.
문제 해결 방법
--from 플래그는 키당 하나의 AWS 비밀을 요구합니다. 20개의 별도 비밀을 만들기는 번거롭죠. 여기서는 AWS CLI와 Python을 이용해 한 줄씩 JSON 전체를 받아와 원하는 키만 추출합니다.
# AWS Secrets Manager: myapp/production/secrets (eu-central-1)
DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)")
ASSEMBLY_AI_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['ASSEMBLY_AI_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)")
REDIS_URL=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['REDIS_URL'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)")
KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)")
각 라인은 전체 JSON을 가져와 하나의 키만 추출합니다. Kamal은 각 라인을 별도 서브쉘에서 평가하므로 변수 공유가 일어나지 않아 문제없이 동작합니다.
jq를 선호한다면 다음과 같이 사용할 수도 있습니다.
DEEPGRAM_API_KEY=$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | jq -r '.DEEPGRAM_API_KEY')
그 후 배포를 실행합니다.
kamal deploy
Kamal은 배포 중 AWS에서 비밀을 가져와 컨테이너에 주입합니다. 평문 파일이 서버에 전혀 남지 않습니다.
환경별 비밀 관리 예시
# .kamal/secrets (kamal deploy 사용)
DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)")
KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)")
# .kamal/secrets.staging (kamal deploy -d staging 사용)
DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)")
KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)")
프로덕션은 myapp/production/secrets, 스테이징은 myapp/staging/secrets 로 이름만 바뀝니다. kamal deploy -d staging을 실행하면 스테이징 파일을 읽어옵니다. 두 비밀 모두 AWS에 저장돼 있으므로 평문이 전혀 없습니다. SOC 2 감사를 대비해 모든 환경에 비밀이 안전하게 관리됩니다.
결론
평문 비밀이 사라졌고, SOC 2와 GDPR 요구사항을 충족했습니다. Hetzner 비용도 월 5유로 이하로 유지됩니다.
AWS 문서팀, Kamal 유지보수자, 그리고 저렴한 호스팅을 제공해 준 Hetzner에 큰 감사를 전합니다. 이 글이 여러분도 같은 문제를 피하는 데 도움이 되길 바랍니다. 이제 다시 개발에 집중할 차례입니다.