Vaultwarden은 정말 안전할까? 보안 강화 가이드
Vaultwarden은 클라이언트 측 암호화를 통해 데이터를 보호하지만, 관리자 토큰과 백업 파일 관리가 보안의 핵심입니다. 서버가 저장하는 데이터의 범위와 자가 호스팅 시 반드시 점검해야 할 설정 오류를 상세히 분석합니다.
Vaultwarden은 안전한가? 짧은 답변
Vaultwarden은 가장 중요한 부분에서 안전을 보장합니다. 모든 볼트 항목은 서버에 도달하기 전에 사용자의 기기에서 암호화되기 때문입니다. 서버는 내용을 읽을 수 없는 데이터 덩어리(blob)를 저장할 뿐입니다. 데이터베이스 전체를 복사해 가더라도, 유용한 정보를 얻으려면 마스터 비밀번호가 반드시 필요합니다.
위 답변은 핵심적인 보안을 다루지만, 실제 문제가 발생하는 지점은 사용자가 직접 설정하는 부분입니다. 추측 가능한 토큰 뒤에 숨겨진 관리자 패널, 인터넷 전체에 노출된 컨테이너 포트, 평문으로 작성된 config.json, 같은 서버의 홈 디렉터리에 방치된 백업 tarball 등이 그 예입니다. 이러한 문제들은 암호화 기술의 결함이 아닙니다. 자가 호스팅 볼트가 탈취되는 모든 원인은 바로 이러한 설정 실수에 있습니다.
아래의 모든 내용은 정상적으로 설치된 환경을 가정합니다. 아직 설치하지 않았다면 먼저 VPS용 Vaultwarden 설치 가이드를 따라 설치를 완료한 뒤, 이 목록을 순서대로 확인하십시오.
서버가 실제로 저장하는 데이터
Vaultwarden은 Bitwarden의 데이터 모델을 구현합니다. 볼트 항목의 이름, 사용자 이름, 비밀번호, 메모, URI는 클라이언트에서 마스터 비밀번호로 파생된 키를 사용하여 요청을 보내기 전에 암호화됩니다. 첨부 파일 내용도 동일한 방식으로 암호화됩니다. 서버는 UUID(범용 고유 식별자)가 첨부된 불투명한 데이터만을 수신합니다.
암호문이 아닌 데이터도 존재하며, 정확히 무엇인지 알아야 합니다.
- 계정 이메일 주소는 일반 텍스트로 저장됩니다.
- KDF(키 파생 함수) 설정과 솔트(salt)는 클라이언트가 다음 로그인 시 키를 재구성해야 하므로 저장됩니다.
- 클라이언트가 전송한 마스터 비밀번호 해시의 서버 측 해시는 로그인 자체를 인증하는 데 사용됩니다.
- 메타데이터: 조직 멤버십, 기기 이름, 마지막 로그인 시간.
- Vaultwarden 로그인을 보호하는 2단계 인증 방식의 비밀 값입니다. 이 값은
twofactor테이블에 암호화되지 않은 상태로 존재하는데, 서버가 사용자의 코드와 비교할 예상 코드를 계산해야 하기 때문입니다. 이는 볼트 항목 내부에 저장하는 TOTP(시간 기반 일회용 비밀번호) 비밀 값과는 다르며, 해당 값은 다른 필드와 마찬가지로 암호화됩니다.
데이터 폴더의 크기는 작습니다. Docker 설치 환경에서는 /data에 마운트한 경로가 곧 데이터 폴더입니다.
sudo ls -l /vw-data/db.sqlite3은 거의 모든 상태 정보를 보유합니다. attachments/는 업로드된 파일을 UUID당 하나씩 저장하며, 데이터베이스 테이블에 저장되지 않는 유일한 중요 데이터 분류입니다. sends/는 Send 첨부 파일을 보관하며 임시 용도로 사용됩니다. icon_cache/은 삭제해도 무방한 데이터입니다. rsa_key.pem과 그 관련 파일들은 로그인한 사용자의 JWT(JSON 웹 토큰)에 서명하므로, 이 개인 키의 복사본이 있으면 볼트 로그인 세션을 위조할 수 있습니다. config.json은 관리자 페이지를 활성화할 때만 생성되며, 프로젝트 측은 이 파일에 관리자 토큰과 SMTP 자격 증명이 일반 텍스트로 저장된다고 명시하고 있습니다.
따라서 실질적인 위협 모델은 네트워크 암호화가 아니라 파일 시스템 접근 권한입니다. 해당 디렉터리에 대한 읽기 권한을 획득하면 모든 사용자의 이메일 주소, 로그인 2FA 비밀 값, 세션을 위조할 수 있는 키, 그리고 오프라인에서 여유롭게 공격할 수 있는 모든 볼트의 복사본이 노출됩니다. 아래의 모든 단계는 외부인이 해당 디렉터리에 접근하지 못하도록 차단하기 위해 존재합니다.
관리자 토큰 먼저 수정하기
/admin는 사용자 목록, 초대, 삭제, 모든 런타임 설정을 포함하는 전체 제어판입니다. 이 제어판은 하나의 공유 비밀값으로만 보호되며, 사용자 이름이나 사용자별 2단계 인증은 지원하지 않습니다.
이전 가이드에서는 openssl rand -base64 48을 사용하여 ADMIN_TOKEN을 생성하도록 안내합니다. 이 방법도 작동하지만, 비밀값이 일반 텍스트로 config.json와 compose 파일에 기록됩니다. Vaultwarden은 Argon2 PHC(Password Hashing Competition) 문자열도 허용하므로, 저장되는 값을 해시로 대체할 수 있습니다. 실행 중인 컨테이너에서 해시를 생성하려면 다음을 수행합니다.
docker exec -it vaultwarden /vaultwarden hash또는 실행 중인 컨테이너를 건드리지 않고 생성하려면 다음을 수행합니다.
docker run --rm -it vaultwarden/server /vaultwarden hash비밀번호를 두 번 입력하면 $argon2id$으로 시작하는 줄이 출력됩니다. 베어메탈 설치 환경이라면 ./vaultwarden hash를 실행하십시오. argon2 CLI를 직접 사용하려면 프로젝트 문서에 명시된 OWASP 최소 권장 매개변수를 사용하십시오.
echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1이제 많은 사용자가 한 시간씩 허비하는 함정을 설명합니다. PHC 문자열에는 $ 문자가 포함되어 있는데, Docker Compose는 $을 변수 보간(variable interpolation)으로 처리합니다. 이를 이스케이프 처리하지 않고 environment: 블록에 붙여넣으면 컨테이너에 전달되는 값이 훼손되어, 올바른 토큰을 입력해도 /admin가 거부하게 됩니다. 두 가지 안전한 방법이 있습니다. docker-compose.yml에서는 $을 두 번 입력하십시오.
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UI.env 파일에서는 이스케이프 처리가 필요 없지만, 작은따옴표를 사용해야 합니다.
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'그다음 제어판에 속도 제한을 걸고 세션 시간을 단축하십시오.
ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=205분 이내에 3번 실패하면 제어판은 해당 클라이언트의 요청을 더 이상 받지 않습니다. 관리자 세션은 20분 동안 활동이 없으면 만료됩니다.
이보다 더 좋은 방법은 페이지 자체를 끄는 것입니다. 대부분의 인스턴스는 SMTP 설정과 초기 사용자 초대를 위해 한 번만 사용하면 다시는 필요하지 않습니다. 페이지를 비활성화하려면 ADMIN_TOKEN과 DISABLE_ADMIN_TOKEN를 모두 설정하지 말고, config.json에서 "admin_token" 키를 제거한 뒤 컨테이너를 다시 생성하십시오. 파일에서 키를 삭제하는 것이 중요한 이유는 관리자 페이지가 설정을 해당 파일에 기록하며, config.json에 있는 값이 환경 변수보다 우선하기 때문입니다. 환경 변수만 제거하면 페이지는 여전히 열려 있게 됩니다.
도메인이 노출되기 전에 가입 기능을 닫으십시오
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=falseSIGNUPS_ALLOWED의 기본값은 true입니다. 이 설정을 그대로 두면 도메인에 접속하는 누구나 계정을 생성할 수 있으며, 해당 사용자의 데이터는 귀하의 데이터와 동일한 db.sqlite3에 저장됩니다. 이 설정을 false로 변경하고, SMTP가 정상적으로 작동하는 관리자 페이지의 초대 기능을 통해 사용자를 추가하십시오. INVITATIONS_ALLOWED 역시 기본적으로 true으로 설정되어 있어 조직 소유자가 다른 사용자를 초대할 수 있습니다. 사용자를 신뢰할 수 있는 환경이라면 괜찮지만, 1인용 인스턴스라면 false로 설정해야 합니다. 특정 도메인의 사용자만 가입하게 하려면 SIGNUPS_DOMAINS_WHITELIST=example.com를 사용할 수 있으나, 이는 공개 가입보다 범위가 좁을 뿐 초대 방식보다는 보안이 훨씬 취약합니다.
SHOW_PASSWORD_HINT은 기본적으로 false이며 이 상태를 유지해야 합니다. 이 기능이 켜져 있으면 로그인 폼에 유효한 이메일 주소를 입력했을 때 해당 계정의 마스터 비밀번호 힌트가 표시됩니다. 이는 힌트가 유출될 뿐만 아니라 해당 이메일 주소가 존재한다는 사실까지 확인해 주는 결과를 초래합니다.
인스턴스의 가입 기능을 잠시라도 열어두었다면, 관리자 페이지에 접속하여 사용자 목록을 확인하십시오. 본인 외에 다른 계정이 없는지 확인하기 전까지는 혼자 사용 중이라고 단정해서는 안 됩니다.
The port you did not mean to publish
The Docker image listens on port 80 inside the container. A bare-metal install defaults to ROCKET_PORT=8000. The documented run command publishes it like this:
--publish 127.0.0.1:8000:80The 127.0.0.1: prefix is the entire point. Write -p 8000:80 instead and Docker binds 0.0.0.0, and it does that by writing DNAT (destination network address translation) rules into the nat table. Those rules are evaluated before the filter chains that ufw manages, so ufw status reports the port as denied while the port cheerfully answers the internet. The full mechanism is worth reading in the guide to Docker ports bypassing ufw.
Check what is really listening:
sudo ss -tlnp | grep 8000A healthy result is a single line bound to 127.0.0.1:8000. A line bound to 0.0.0.0:8000 means the vault is exposed directly. Fix the mapping, then recreate the container, because a port binding is fixed when the container is created and docker compose restart will not change it:
docker compose up -d --force-recreateOne more port survives in old guides: 3012, the separate WebSocket port. Support for it was removed in Vaultwarden 1.31.0, because notification traffic moved onto the main HTTP port. WEBSOCKET_ENABLED and WEBSOCKET_PORT have been ignored since 1.29.0. The current switch is ENABLE_WEBSOCKET, which defaults to true. If your firewall or compose file still opens 3012, close it.
Rocket이 아닌 리버스 프록시에서 TLS 종료하기
Vaultwarden은 웹 프레임워크인 Rocket을 통해 자체적으로 TLS(전송 계층 보안)를 제공할 수 있지만, 프로젝트 측에서는 운영 환경에서 이를 사용하지 말 것을 권장합니다. Rocket의 내장 TLS는 엄격한 SNI(서버 이름 표시) 지원이 부족합니다. 이것이 바로 보안 강화 권고 사항에서 인스턴스에 접근할 때 IP 주소가 아닌 호스트 이름을 사용하라고 하는 이유입니다. 공인 IP 대역은 끊임없이 스캔되며, IP 주소로 응답하는 볼트는 곧바로 발견됩니다.
nginx 서버 블록에서 중요한 부분은 다음과 같습니다.
client_max_body_size 525M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}nginx의 기본값인 client_max_body_size는 1 MB입니다. 따라서 해당 줄이 없으면 첨부 파일 업로드 시 nginx 오류 로그에 413 Request Entity Too Large이 기록되면서 실패하게 되며, Vaultwarden 로그에는 아무것도 남지 않습니다. Upgrade 및 Connection 헤더는 WebSocket 핸드셰이크를 /notifications/hub으로 전달합니다. 이 설정을 제거하면 볼트는 정상 작동하지만, 페이지를 수동으로 새로고침하기 전까지는 다른 기기에서 변경 사항이 반영되지 않습니다.
Caddy는 설정이 더 간결하며 인증서를 자동으로 발급받습니다.
vw.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up X-Real-IP {remote_host}
}
}그다음 Vaultwarden에 해당 설정을 알립니다.
DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IPIP_HEADER은 이미 기본값이 X-Real-IP로 설정되어 있으므로, 프록시가 실제로 해당 헤더를 설정하는지 확인하는 것이 중요합니다. 만약 설정하지 않으면 모든 로그 라인과 로그인 속도 제한(rate limit)이 프록시 자체인 127.0.0.1로 인식됩니다. 이는 공격자 한 명의 실패가 인스턴스의 모든 사용자에게 영향을 미치게 됨을 의미합니다. 또한 DOMAIN을 실제 https URL로 설정하십시오. Vaultwarden은 이를 기반으로 초대 및 비밀번호 재설정 링크를 생성하며, WebAuthn 보안 키도 해당 오리진에 바인딩되기 때문입니다.
사람들이 자주 놓치는 세부 사항이 하나 있습니다. WebSocket 연결은 세션 토큰을 쿼리 문자열인 /notifications/hub?access_token=[JWT]로 전달합니다. 이 정보는 프록시 접근 로그에 평문으로 기록됩니다. 로그 형식에서 access_token 매개변수를 마스킹하거나, 해당 로그가 제어할 수 없는 곳으로 전송되지 않도록 주의하십시오.
로그인 엔드포인트에서 무차별 대입 공격 차단하기
속도 제한은 기본적으로 활성화되어 있습니다(LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). 이는 공격자의 속도를 늦추지만, 공격 자체를 완전히 막지는 못합니다. fail2ban은 공격을 차단할 수 있지만, Vaultwarden이 먼저 로그 파일을 기록해야 합니다. Vaultwarden은 기본 설정에서 로그를 기록하지 않으므로 다음 설정을 추가해야 합니다:
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=true로그인 실패 시 정확히 한 줄의 로그가 생성되며, 필터는 이 문자열을 일치시켜야 합니다:
[2026-08-07 09:14:22][vaultwarden::api::identity][ERROR] Username or password is incorrect. Try again. IP: 203.0.113.10. Username: user@example.com./etc/fail2ban/filter.d/vaultwarden.local에 필터를 작성합니다:
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =그리고 /etc/fail2ban/jail.d/vaultwarden.local에 jail을 작성합니다:
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
banaction = %(banaction_allports)s
logpath = /vw-data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400관리자 페이지를 유지하고 있다면 failregex이 ^.*Invalid admin token\. IP: <ADDR>.*$인 두 번째 jail을 추가하십시오. 관리자 페이지 로그인 실패는 다른 메시지로 기록되므로 일반 로그인 필터로는 감지할 수 없기 때문입니다. 설정이 완료되면 다음 명령으로 확인합니다:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden정상적으로 작동하는 jail은 File list 항목에 로그 파일 경로를 표시하고 Currently failed: 0 상태를 보고합니다. 다른 네트워크에서 잘못된 비밀번호를 3회 입력하면 카운터가 올라가고, 이후 해당 주소가 Banned IP list 항목에 나타납니다. 카운터가 전혀 변하지 않는다면 일반적인 원인은 logpath입니다. 이는 컨테이너 내부의 /data/... 경로가 아닌, 호스트 시스템의 실제 파일 경로여야 합니다. 두 번째로 흔한 원인은 X-Real-IP가 누락된 경우입니다. 이 설정이 없으면 모든 차단 대상이 프록시 서버 자신으로 지정됩니다. 이미 실행 중이어야 할 SSH jail을 포함한 나머지 설정은 Ubuntu 24.04용 fail2ban 가이드에서 확인할 수 있습니다.
마스터 비밀번호가 여전히 시스템 전체의 보안을 결정합니다
클라이언트 측 암호화에서 마스터 비밀번호는 곧 암호화 키를 의미합니다. 공격자가 데이터베이스를 복사해 간 인스턴스에서 마스터 비밀번호가 짧다면, 이 글에서 다루는 어떤 설정으로도 보호받을 수 없습니다. 공격자는 자신의 하드웨어가 허용하는 속도로 오프라인에서 해당 복사본을 공격하기 때문입니다. 서버 측의 어떤 설정도 공격자의 로컬 머신에는 영향을 미치지 못합니다.
PASSWORD_ITERATIONS=600000는 사용자가 새 계정을 생성할 때 클라이언트에 전달되는 KDF 반복 횟수입니다. 기존 계정은 생성 당시의 값을 그대로 유지하므로, 이 값을 높여도 작년에 가입한 사용자에게는 아무런 변화가 없습니다. 사용자가 직접 웹 볼트의 보안 설정에서 이를 변경해야 하며, 이 과정에서 키가 다시 암호화됩니다. 인터페이스상에는 아무런 안내가 없으므로 사용자에게 직접 알려야 합니다.
그다음으로 계정별 2단계 인증(2FA)을 활성화하십시오. 볼트 키는 오직 마스터 비밀번호에서 생성되므로 2단계 인증이 암호문 자체를 보호하지는 않습니다. 하지만 비밀번호를 탈취당하더라도 로그인하여 데이터를 동기화하는 것을 막을 수는 있습니다. REQUIRE_DEVICE_EMAIL=true은 인식되지 않은 기기에서 처음 로그인할 때 이메일 인증 단계를 추가합니다.
백업은 셀프 호스팅 볼트가 실패하는 지점입니다
동일한 VPS의 홈 디렉터리에 남겨둔 데이터 폴더의 tar czf은 앞서 수행한 모든 보안 조치를 무효화합니다. 해당 아카이브에는 모든 사용자의 암호문이 담긴 db.sqlite3, 로그인 세션을 위조할 수 있는 rsa_key.pem, 그리고 관리자 토큰과 SMTP 비밀번호가 평문으로 저장된 config.json이 포함되어 있습니다. 이 파일에 대한 읽기 권한이 있다는 것은 곧 볼트에 대한 읽기 권한이 있다는 의미입니다.
이를 해결하는 두 가지 규칙이 있습니다. 아카이브를 서버 외부로 반출하십시오. 반출하기 전에 반드시 암호화하십시오.
데이터의 무결성 문제도 존재합니다. 서비스가 실행 중인 상태에서 cp를 사용하여 db.sqlite3을 복사하면, 쓰기 작업이 진행 중인 파일이 생성되어 열리지 않을 수 있습니다. 이 문제는 복구 시점에야 비로소 발견하게 됩니다. 대신 SQLite 자체 스냅샷 기능을 사용하십시오.
sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"아무도 테스트하지 않는 절반의 과정인 복구 작업은 Vaultwarden 백업 및 복구 가이드에서 다룹니다.
호스팅된 Bitwarden과 비교했을 때 포기해야 하는 것
솔직한 평가가 필요합니다. Bitwarden의 호스팅 서비스는 이를 운영하는 것을 전업으로 하는 전문가들이 관리하며, 공개된 제3자 보안 감사를 거치고 새벽 3시에도 대응할 수 있는 인력이 대기하고 있습니다. 직접 호스팅을 선택한다는 것은 이러한 관리 체계를 사용자의 패치 주기로 대체한다는 의미입니다.
Vaultwarden은 보안 수정 사항을 일반적인 릴리스 형태로 배포합니다. 2026년 7월 24일에 릴리스된 버전 1.37.0이 2026년 8월 기준으로 최신 버전이며, 릴리스 노트는 사용자에게 가능한 한 빨리 업데이트할 것을 권고합니다. 1년 전에 설치하고 잊어버린 인스턴스는 1년 된 코드를 실행하고 있는 셈입니다. latest 태그만으로는 충분하지 않습니다. 실행 중인 컨테이너는 docker compose pull를 실행하여 다시 생성하기 전까지는 처음 시작할 때의 이미지를 그대로 유지하기 때문입니다. 호스트 패키지를 위해 Ubuntu의 자동 업그레이드를 설정하고, 컨테이너 업데이트는 실제로 확인할 수 있는 일정 알림에 등록해 두어야 합니다.
솔직한 독자라면 다음과 같은 결론을 내려야 합니다. 이곳의 암호화 방식은 Bitwarden의 설계를 따르므로 안전하지만, 운영상의 위험은 전적으로 사용자에게 넘어옵니다. 패치를 꾸준히 수행하고 다른 곳에 백업을 유지한다면, 사용자가 제어하는 VPS상의 Vaultwarden 인스턴스는 비밀번호를 보관하기에 합리적인 장소입니다. 만약 이러한 두 가지 습관을 지킬 수 없다면, 호스팅 서비스를 구매하고 다른 곳에 주의를 기울이는 것이 좋습니다. 기능별 비교는 Vaultwarden과 자체 호스팅 Bitwarden 비교에서 확인할 수 있습니다.
컨테이너 하위 호스트 보안 강화
Vaultwarden은 Linux 시스템에서 하나의 프로세스로 실행되며, 애플리케이션 설정과 관계없이 해당 시스템의 root 권한을 가진 사용자는 /vw-data를 읽을 수 있습니다. compose 파일에 user: "1000:1000"을 설정하여 컨테이너를 권한이 없는 사용자로 실행하고, 데이터 폴더의 소유권을 이에 맞게 변경하십시오. 또한 컨테이너가 쓰기 작업을 수행하지 않는 항목은 :ro 옵션을 사용하여 읽기 전용으로 마운트하십시오. 마지막으로 외부 접근을 차단하십시오. VPS의 SSH 보안 강화 문서에서는 키 기반 로그인 설정 및 비밀번호 인증 비활성화를 다루고 있으며, 이는 앞서 언급한 모든 보안 조치를 우회하려는 일반적인 공격을 방어하는 핵심 수단입니다.
FAQ
Can someone read my passwords if they steal the Vaultwarden database?
Not directly. Every vault item is encrypted in the client with a key derived from the master password, so db.sqlite3 contains ciphertext. What they get immediately is each account's email address, the KDF settings, login and device metadata, and the two-factor secrets in the twofactor table, which are stored unencrypted because the server must compute the expected code. They can also attack the vault ciphertext offline for as long as they like, which is why master password length is the number that decides the outcome.
Should I use ADMIN_TOKEN or disable the admin page completely?
Disable it if you can, since most instances need it once to configure SMTP and invite users and never again. To disable it, set neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. Removing only the environment variable is not enough, because settings written by the admin page live in config.json and take precedence. If you do keep the page, store the token as an Argon2 hash produced by vaultwarden hash rather than a plaintext random string, and set ADMIN_RATELIMIT_MAX_BURST=3.
My ADMIN_TOKEN is correct but /admin rejects it. What is wrong?
Almost always $ interpolation. An Argon2 PHC string contains several $ characters, and Docker Compose expands them as variables inside a docker-compose.yml environment: block, so the container receives a mangled value while your file looks right. Double every $ to $$ in the compose file, or move the value into an .env file wrapped in single quotes, where no escaping is needed. Recreate the container afterwards, since environment changes are not picked up by a restart.
Do I still need to open port 3012 for notifications?
No. Support for WebSocket traffic on port 3012 was removed in Vaultwarden 1.31.0 because notifications moved onto the main HTTP port, and WEBSOCKET_ENABLED and WEBSOCKET_PORT have been ignored since 1.29.0. The current setting is ENABLE_WEBSOCKET, which is true by default. Close 3012 in the firewall and delete it from your compose file, then make sure your reverse proxy forwards the Upgrade and Connection headers, because that is what real-time sync actually depends on now.