levish
levish
이전으로
Vaultwarden을 Cloudflare Tunnel로 셀프호스팅하기

Vaultwarden을 Cloudflare Tunnel로 셀프호스팅하기

Cloudflare Tunnel과 Docker로 password vault인 Vaultwarden을 직접 올리면서 정리한 기록입니다. 터널 구성부터 Resend 메일 연동, secret 공유까지.

June 26, 2026

개발인프라셀프호스팅

시작하며

협업 프로젝트에서 팀이 같이 쓰는 비밀번호나 API 키는 늘 Vaultwarden에 모아 뒀습니다. 그동안은 다른 사람이 띄워 둔 인스턴스에 초대받아 키만 꺼내 쓰는 쪽이었는데, 이번에 개발 리드를 맡으면서 인프라를 처음부터 세팅하게 됐고 Vaultwarden도 제가 직접 올리게 됐습니다. 나중에 또 세팅할 일이 있을 것 같아서 순서대로 적어 둡니다.

Vaultwarden은 Bitwarden 서버를 Rust로 다시 구현한 오픈소스입니다. 공식 Bitwarden 클라이언트(브라우저 확장, 데스크톱, 모바일)를 그대로 쓰면서 더 가볍습니다.

서버에는 이미 cloudflared가 systemd 서비스로 돌고 있었습니다. 그래서 외부 접근은 전부 Cloudflare Tunnel로 받기로 했습니다. 443이나 80을 직접 여는 대신 cloudflared가 아웃바운드로 Cloudflare에 붙어 있고, 트래픽은 이 터널을 타고 들어옵니다. 덕분에 서버 쪽 포트를 따로 열 필요가 없고, 서버 IP도 드러나지 않습니다. TLS도 Cloudflare가 처리해 줍니다.

이미지 태그는 latest 대신 버전을 박아 썼습니다. vault는 데이터가 중요하니, 무심코 pull 한 번에 마이그레이션이 끼어들어 깨지는 일은 피하고 싶었거든요. 작성 시점 최신 안정 버전인 1.36.0-alpine을 골랐습니다.

디렉터리부터 잡기

서비스마다 디렉터리를 하나 파고 그 안에서 관리하면 깔끔합니다. compose 파일의 볼륨을 상대 경로로 잡으면, compose 파일이 있는 곳이 곧 데이터가 쌓이는 곳이 됩니다.

/opt/vaultwarden/
├── docker-compose.yml
└── vw-data/          # db.sqlite3, attachments 등 vault 전체

위치는 /opt/<service>//srv/<service>/가 관례입니다. 나중에 백업할 때도 이 vw-data 하나만 챙기면 끝입니다.

sudo mkdir -p /opt/vaultwarden
cd /opt/vaultwarden

ADMIN_TOKEN 만들기

/admin 관리 패널에 들어가려면 ADMIN_TOKEN이 필요합니다. 평문을 그대로 넣어도 동작은 하지만, 파일이 유출되면 패널이 바로 뚫리니 Argon2 해시로 넣어 두는 편이 안전합니다.

먼저 토큰으로 쓸 강한 비밀번호를 하나 만듭니다.

openssl rand -base64 48

이 문자열이 /admin 로그인에 입력할 평문 비밀번호입니다. 안전한 곳에 따로 보관해 둡니다. 그다음 이 값을 Vaultwarden에 내장된 해시 생성기에 넣습니다.

docker run --rm -it vaultwarden/server:1.36.0-alpine /vaultwarden hash

프롬프트에 위 평문을 입력하면 $argon2id$...로 시작하는 해시가 나옵니다. 이 해시를 ADMIN_TOKEN에 넣으면 됩니다.

한 가지 주의할 점은, compose YAML 안에서는 $를 전부 $$로 이스케이프해야 한다는 것입니다. 그러지 않으면 Docker가 변수로 해석해서 토큰이 깨집니다. 이게 번거로우면 .env 파일에 작은따옴표로 감싸 넣어도 됩니다.

docker-compose.yml

cloudflared는 호스트 systemd로 따로 돌고 있으니, compose에는 Vaultwarden 하나만 둡니다. 포트는 127.0.0.1에만 바인딩해서 외부에서 직접 닿지 못하게 막고, 터널을 거쳐야만 들어오게 합니다.

services:
  vaultwarden:
    image: vaultwarden/server:1.36.0-alpine
    container_name: vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: 'https://vault.example.com'
      SIGNUPS_ALLOWED: 'true' # 첫 계정 만든 뒤 false로
      ADMIN_TOKEN: '$$argon2id$$v=19$$...'
    volumes:
      - ./vw-data/:/data/
    ports:
      - 127.0.0.1:12000:80

DOMAIN은 실제 접속 도메인과 정확히 같아야 합니다. 어긋나면 WebAuthn/passkey나 알림 같은 기능이 깨집니다. 뒤에서 터널에 등록할 호스트네임과도 일치시켜야 합니다.

포트 매핑 127.0.0.1:12000:80을 풀어 보면, 오른쪽 80은 컨테이너 안에서 Vaultwarden이 실제로 듣는 포트라 고정입니다. 최신 버전은 WebSocket까지 이 80 하나로 처리합니다. 왼쪽 호스트 포트는 다른 서비스와 겹치지만 않으면 아무 번호나 써도 됩니다. 127.0.0.1에 묶여 있어서 바깥에서 서버IP:12000으로는 닿을 수 없고, 같은 호스트의 cloudflared를 거쳐야만 들어옵니다.

SIGNUPS_ALLOWED는 기본이 가입 허용입니다. 첫 계정을 만든 다음엔 반드시 false로 잠가야 합니다.

cloudflared로 터널 연결하기

systemd로 돌리는 cloudflared config에 ingress 규칙을 넣습니다. 숫자는 compose의 호스트 포트(12000)와 맞춥니다.

ingress:
  - hostname: vault.example.com
    service: http://localhost:12000
  - service: http_status:404

cloudflared가 호스트 프로세스라 localhost:12000으로 컨테이너의 노출 포트에 바로 닿습니다. 컨테이너 이름은 호스트 네임스페이스에서 풀리지 않기 때문에, 이름 대신 포트를 노출하는 방식을 씁니다. config 파일을 고쳤으면 재시작해 줍니다.

sudo systemctl restart cloudflared

컨테이너 띄우기

cd /opt/vaultwarden
sudo docker compose up -d
sudo docker compose logs -f

restart: unless-stopped 덕분에 한 번 올려 두면 서버를 재부팅하거나 Docker가 재시작돼도 알아서 다시 뜹니다. 평소엔 손댈 일이 없고, up -d를 다시 치는 건 compose 파일을 바꿨을 때(가입 잠금, 버전 업데이트 등)뿐입니다.

헷갈리기 쉬운 부분 하나. 포트나 환경변수를 바꿨을 땐 docker compose restart가 아니라 docker compose up -d를 써야 합니다. restart는 같은 설정 그대로 껐다 켜는 것이라, 바뀐 내용이 반영되지 않습니다.

이제 브라우저로 https://vault.example.com에 들어가면 로그인 화면이 뜹니다.

첫 계정 만들고 가입 잠그기

뜬 화면에서 회원가입(Create account)을 하고 vault의 마스터 비밀번호를 정합니다. 이 비밀번호는 복구가 안 되니 절대 잊으면 안 됩니다.

계정을 만들고 로그인까지 확인했으면 바로 가입을 잠급니다. 지금은 아무나 계정을 만들 수 있는 상태이기 때문입니다.

SIGNUPS_ALLOWED: 'false'
sudo docker compose up -d

Cloudflare Access는 vault 전체에 걸지 않기

보안을 더 두르고 싶어도 vault 호스트 전체에 Cloudflare Access(Zero Trust)를 걸면 안 됩니다. Bitwarden 클라이언트 앱이 Access 로그인 페이지를 넘지 못해서 동기화가 깨집니다. 굳이 건다면 루트 도메인 대신 /admin 경로에만 걸고, 로그인 보호는 계정 2FA(TOTP)로 합니다. 2FA는 클라이언트 앱과도 잘 호환됩니다.

Resend로 메일 보내기

초대 메일, 2FA 메일, 로그인 알림 같은 기능을 쓰려면 SMTP가 필요합니다. Vaultwarden은 메일 서버가 아니라서, 외부 SMTP 계정을 환경변수로 등록해 주면 그걸 통해 메일을 보냅니다. 저는 Resend를 썼습니다. 트랜잭셔널 메일 서비스고, 무료 티어가 월 3,000통(하루 100통)이라 셀프호스팅 알림 용도로는 충분합니다.

먼저 Resend 대시보드에서 Add domain으로 발송에 쓸 도메인(example.com)을 등록합니다. Region은 서버나 사용자와 가까운 곳(여기선 Tokyo)을 고릅니다. 등록하면 DKIM/SPF 관련 DNS 레코드가 나오는데, 이걸 도메인 DNS(여기선 Cloudflare)에 그대로 넣어 주면 검증이 끝납니다.

DKIM 검증은 도메인 단위라서, 도메인 하나만 검증해 두면 그 도메인의 어떤 주소로든 보낼 수 있습니다. noreply@example.com이든 support@example.com이든 상관없습니다. 회원가입·인증·알림처럼 답장받지 않는 자동 메일은 보통 전부 noreply@로 보냅니다.

다음으로 Resend의 API Keys에서 키를 발급합니다. 발송만 하면 되니 권한은 Sending access로 충분합니다. Full access 키가 새면 도메인 삭제나 키 발급까지 가능해 피해가 크니, 최소 권한으로 두는 게 안전합니다. 키는 re_로 시작하고 생성 직후 한 번만 보이니 그때 복사해 둡니다.

Resend SMTP는 호스트와 username이 고정값입니다.

  • Host: smtp.resend.com
  • Username: resend (말 그대로 고정값이고, 이메일 주소가 아닙니다)
  • Password: 발급받은 re_... API 키

계정을 username으로 구분하지 않고 인증과 권한을 전부 API 키에 담는 설계라, username은 누가 쓰든 동일합니다.

이제 environment 블록에 SMTP 설정을 더합니다.

environment:
  DOMAIN: 'https://vault.example.com'
  SIGNUPS_ALLOWED: 'false'
  ADMIN_TOKEN: '$$argon2id$$...'
  SMTP_HOST: 'smtp.resend.com'
  SMTP_FROM: 'noreply@example.com'
  SMTP_FROM_NAME: 'Vaultwarden'
  SMTP_SECURITY: 'starttls'
  SMTP_PORT: '587'
  SMTP_USERNAME: 'resend'
  SMTP_PASSWORD: 're_발급받은_키'

SMTP_FROM_NAME을 지정하면 받는 사람에게 Vaultwarden <noreply@example.com>으로 보입니다.

sudo docker compose up -d

설정을 마쳤으면 바로 확인해 봅니다. https://vault.example.com/admin에 평문 비밀번호로 로그인한 뒤 Settings → SMTP Email Settings 맨 아래의 Send test email로 본인 메일에 한 통 보냅니다. 받은편지함에 잘 도착하면 성공입니다. 안 오면 스팸함을 보고, 그래도 없으면 Resend 대시보드의 Logs 탭에서 발송 시도와 실패 사유를 확인할 수 있습니다.

사용자 초대하고 secret 공유하기

가입을 잠근 상태에서 사람을 추가하려면 /admin의 Users 탭에서 이메일로 Invite 합니다. SMTP가 붙어 있으면 초대 메일이 자동으로 나가고, 받은 사람은 링크를 타고 들어와 자기 마스터 비밀번호를 정해 가입합니다.

한 가지, 관리자가 사용자의 비밀번호를 대신 정해 줄 수는 없습니다. 마스터 비밀번호는 단순한 로그인 암호가 아니라 vault를 푸는 암호화 키이고, 클라이언트 쪽에서만 다뤄져서 서버(관리자)는 평문을 보지 못합니다. 그래서 비밀번호는 늘 사용자 본인이 정합니다.

팀이 같은 secret(API 키, 공유 계정 같은 것)을 함께 봐야 한다면 Organization을 씁니다. 일반 web vault에서 New Organization을 만들고, collection을 용도별로 나눈 다음(예: Infra Secrets, 프로젝트별) 멤버를 초대해 collection 단위로 접근 권한을 줍니다. 모두가 모든 secret을 볼 필요는 없으니, 특히 외부 협업자가 끼면 collection으로 보이는 범위를 끊어 두는 게 좋습니다.

secret은 보통 Secure Note로 저장하고, 키 값과 함께 서비스명·용도·권한·발급일 같은 메타정보를 적어 둡니다. 나중에 키를 교체(rotation)할 때 헷갈리지 않습니다.

하나 덧붙이면, org vault를 런타임 의존 대상으로 쓰진 않는 게 좋습니다. org vault는 “사람이 들여다보는 secret 카탈로그”로만 두고, 애플리케이션이 런타임에 읽는 키는 각 서버의 .env에 둡니다. 앱이 메일 보낼 때마다 Vaultwarden API로 키를 꺼내 오게 만들면 순환 의존과 단일 장애점이 생깁니다. 규모가 더 커지면 그건 password manager가 아니라 Infisical이나 HashiCorp Vault 같은 전용 secret manager가 맡을 영역입니다.

백업과 업데이트는 직접 챙기기

평소엔 손 떼고 둬도 되지만, 의식적으로 챙겨야 하는 두 가지가 있습니다.

하나는 백업입니다. restart 정책은 컨테이너가 죽으면 살려 주지만, 디스크가 날아가거나 vw-data가 손상되면 손쓸 방법이 없습니다. vault 전체가 vw-data 디렉터리 하나에 담겨 있으니, cron으로 주기적으로 압축해 다른 위치나 스토리지로 복사해 두면 됩니다.

다른 하나는 업데이트입니다. 버전을 고정한 만큼 안정적이지만, 뒤집어 말하면 보안 패치도 자동으로 오지 않는다는 뜻입니다. 분기에 한 번쯤 릴리스 노트(특히 보안 패치와 마이그레이션 항목)를 확인하고, 백업을 뜬 다음 compose의 태그를 새 버전으로 올려 up -d 하는 리듬이 적당합니다. self-hosted web-vault가 서버 이미지에 같이 묶여 나오기 때문에, 버전을 고정해 두면 서버와 web UI 사이의 버전 어긋남도 자연히 사라집니다.

이제 남은 건 2FA를 켜고 백업 cron을 거는 정도입니다. 여기서 잡은 Resend 설정은 다른 백엔드의 회원가입·인증 메일에도 SMTP 값 네 개만 바꿔 끼우면 그대로 다시 씁니다.

© 2026 levish