האם Vaultwarden מאובטח? מדריך אבטחה והקשחה
Vaultwarden מצפין את כל פריטי הכספת בצד הלקוח כך שהשרת נותר ללא מידע גלוי. גלו מהם הסיכונים האמיתיים בניהול ה-admin token וכיצד לאבטח נכון את קבצי הגיבוי שלכם.
האם Vaultwarden מאובטח? התשובה הקצרה
Vaultwarden מאובטח במקום החשוב ביותר, כיוון שכל פריט בכספת מוצפן במכשיר שלכם לפני שהוא מגיע לשרת. השרת מאחסן גושי מידע (blobs) שאין ביכולתו לקרוא. מי שיעתיק את מסד הנתונים בשלמותו עדיין יזדקק לסיסמת האב כדי להפיק ממנו מידע שימושי כלשהו.
התשובה הזו מסתמכת על הנחות רבות, והחלקים שעלולים להיכשל הם אלו שאתם מגדירים. פאנל ניהול מאחורי אסימון (token) שניתן לנחש. פורט של מכולה (container) החשוף לכל האינטרנט. קובץ config.json בטקסט גלוי. ארכיון גיבוי בפורמט tarball שנמצא בתיקיית הבית על אותו שרת. אף אחד מאלו אינו בעיה קריפטוגרפית. כולם מהווים את הסיבות לכך שכספות באירוח עצמי נפרצות.
כל המפורט להלן מניח קיומה של התקנה תקינה. אם טרם ביצעתם התקנה, הגדירו אותה תחילה באמצעות מדריך ההתקנה של Vaultwarden לשרת VPS, ולאחר מכן חזרו לכאן ועבדו לפי סדר הרשימה.
מה השרת מאחסן בפועל
Vaultwarden מיישם את מודל הנתונים של Bitwarden. השם, שם המשתמש, הסיסמה, ההערות והכתובות (URIs) של פריט בכספת מוצפנים בצד הלקוח באמצעות מפתח שנגזר מסיסמת האב, וזאת לפני שליחת כל בקשה. תוכן הקבצים המצורפים מוצפן באותו אופן. השרת מקבל נתונים אטומים שמוצמד אליהם UUID (מזהה ייחודי אוניברסלי).
חלק מהנתונים אינם מוצפנים, ועליך לדעת בדיוק מהם:
- כתובת האימייל של החשבון, בטקסט גלוי.
- הגדרות ה-KDF (פונקציית גזירת מפתח) וה-salt, כיוון שהלקוח זקוק להם כדי לשחזר את המפתח בכניסה הבאה.
- Hash בצד השרת של ה-hash של סיסמת האב שנשלח מהלקוח, המשמש לאימות הכניסה עצמה.
- מטא-דאטה: חברות בארגון, שמות מכשירים, זמני כניסה אחרונים.
- הסוד עבור שיטת האימות הדו-שלבי (2FA) המגנה על הכניסה ל-Vaultwarden. הוא נשמר בטבלה
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 הוא לוח בקרה מלא: רשימת משתמשים, הזמנות, מחיקה וכל הגדרת זמן ריצה. הוא מוגן על ידי סוד משותף אחד בלבד. ללא שם משתמש. ללא אימות דו-שלבי לכל משתמש.
מדריכים ישנים מנחים ליצור ADMIN_TOKEN באמצעות openssl rand -base64 48. זה עובד, והוא כותב את הסוד בטקסט גלוי לתוך config.json ולתוך קובץ ה-compose שלכם. Vaultwarden מקבל גם מחרוזת Argon2 PHC (תחרות גיבוב סיסמאות), כך שהערך המאוחסן הוא גיבוב במקום זאת. צרו אחד מול מכולה רצה:
docker exec -it vaultwarden /vaultwarden hashאו מבלי לגעת במכולה הרצה כלל:
docker run --rm -it vaultwarden/server /vaultwarden hashהוא יבקש סיסמה פעמיים, ולאחר מכן ידפיס שורה המתחילה ב-$argon2id$. בהתקנה על שרת חשוף (bare-metal), הריצו את ./vaultwarden hash. אם אתם מעדיפים להשתמש ישירות ב-CLI של argon2, הפרויקט מתעד את הפרמטרים המינימליים של OWASP:
echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1כעת המלכודת שגוזלת לאנשים שעה של עבודה. מחרוזת PHC מלאה בתווי $, ו-Docker Compose מתייחס ל-$ כאל אינטרפולציה של משתנים. הדבקה ללא בריחה (unescaped) לתוך בלוק 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=20שלושה ניסיונות כושלים בתוך חמש דקות יגרמו ללוח להפסיק להשיב לאותו לקוח. סשן הניהול יפוג לאחר 20 דקות של חוסר פעילות.
טוב יותר מכל זה: כבו את הדף. רוב המופעים זקוקים לו פעם אחת בלבד, כדי להגדיר SMTP ולהזמין את המשתמשים הראשונים, ולאחר מכן לעולם לא. כדי להשבית אותו, אל תגדירו לא את ADMIN_TOKEN ולא את DISABLE_ADMIN_TOKEN, הסירו כל מפתח "admin_token" מ-config.json, ולאחר מכן צרו מחדש את המכולה. מחיקת המפתח מהקובץ חשובה מכיוון שדף הניהול כותב הגדרות לשם, ומה שנמצא ב-config.json גובר על משתני הסביבה. הסרת המשתנה בלבד תשאיר את הדף פתוח.
סגירת הרישום לפני שמישהו ימצא את הדומיין
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=falseSIGNUPS_ALLOWED מוגדר כברירת מחדל ל-true. אם תותירו הגדרה זו על כנה, כל מי שיגיע לדומיין שלכם יוכל ליצור חשבון, והנתונים שלו יישמרו באותו db.sqlite3 שבו נשמרים הנתונים שלכם. הגדירו זאת ל-false והוסיפו משתמשים באמצעות הזמנות מדף הניהול, מה שמחייב הגדרת SMTP תקינה. INVITATIONS_ALLOWED מוגדר גם הוא כ-true כברירת מחדל, ומאפשר לבעלי ארגונים להזמין משתמשים אחרים. הגדרה זו מקובלת כאשר אתם סומכים על המשתמשים שלכם, אך במופע (instance) של משתמש יחיד, יש להגדיר זאת כ-false. אם ברצונכם להגביל את הרישום לדומיינים מסוימים בלבד, SIGNUPS_DOMAINS_WHITELIST=example.com היא אפשרות מצומצמת יותר מרישום פתוח, אך היא חלשה משמעותית משימוש בהזמנות.
SHOW_PASSWORD_HINT מוגדר כ-false כברירת מחדל, וכך עליו להישאר. כאשר הגדרה זו פעילה, הקלדת כתובת דוא"ל תקפה בטופס ההתחברות תציג את רמז הסיסמה הראשית של אותו חשבון; פעולה זו גם חושפת את הרמז וגם מאשרת שהכתובת קיימת במערכת.
אם המופע שלכם פעל עם רישום פתוח ולו לזמן קצר, פתחו את דף הניהול ועברו על רשימת המשתמשים לפני שתניחו שאתם המשתמשים היחידים במערכת.
הפורט שלא התכוונת לחשוף
ה־image של Docker מאזין לפורט 80 בתוך המכולה. התקנה על גבי שרת פיזי (bare-metal) משתמשת כברירת מחדל ב-ROCKET_PORT=8000. פקודת ה-run המתועדת מפרסמת אותו כך:
--publish 127.0.0.1:8000:80הקידומת 127.0.0.1: היא לב העניין. אם תכתוב -p 8000:80 במקום זאת, Docker יבצע bind ל-0.0.0.0, והוא עושה זאת על ידי כתיבת חוקי DNAT (תרגום כתובות רשת ליעד) לתוך טבלת ה-nat. חוקים אלו נבדקים לפני שרשראות ה-filter שמנוהלות על ידי ufw, לכן ufw status מדווח על הפורט כחסום בזמן שהפורט משיב בשמחה לבקשות מהאינטרנט. כדאי לקרוא על המנגנון המלא ב-מדריך לעקיפת ufw על ידי פורטים של Docker.
בדוק מה באמת מאזין:
sudo ss -tlnp | grep 8000תוצאה תקינה היא שורה אחת שמאזינה ל-127.0.0.1:8000. שורה שמאזינה ל-0.0.0.0:8000 משמעותה שה-vault חשוף ישירות. תקן את המיפוי ולאחר מכן צור מחדש את המכולה, כיוון שקישור פורט נקבע בעת יצירת המכולה ו-docker compose restart לא ישנה זאת:
docker compose up -d --force-recreateפורט נוסף ששורד במדריכים ישנים הוא 3012, פורט ה-WebSocket הנפרד. התמיכה בו הוסרה ב-Vaultwarden 1.31.0, כיוון שתעבורת ההתראות עברה לפורט ה-HTTP הראשי. WEBSOCKET_ENABLED ו-WEBSOCKET_PORT מתעלמים ממנו מאז גרסה 1.29.0. המתג הנוכחי הוא ENABLE_WEBSOCKET, שברירת המחדל שלו היא true. אם ה-firewall או קובץ ה-compose שלך עדיין פותחים את 3012, סגור אותו.
סיום TLS ב-reverse proxy, ולא ב-Rocket
Vaultwarden יכול להגיש TLS (אבטחת שכבת תעבורה) בעצמו דרך Rocket, תשתית ה-web שלו, אך הפרויקט ממליץ שלא לעשות זאת בסביבת production. ל-TLS המובנה של Rocket חסרה תמיכה קפדנית ב-SNI (חיווי שם שרת), וזו גם הסיבה שההמלצה להקשחת האבטחה היא לגשת למופע שלכם לפי שם מתחם (hostname) ולא לפי כתובת 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, לכן ללא שורה זו העלאת קבצים מצורפים תיכשל עם 413 Request Entity Too Large בלוג השגיאות של nginx, בעוד ש-Vaultwarden לא יתעד דבר. ה-headers Upgrade ו-Connection מעבירים את ה-handshake של ה-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-IPהערך IP_HEADER מוגדר כברירת מחדל ל-X-Real-IP, לכן המשימה היא לוודא שה-proxy אכן מגדיר את ה-header הזה. אם לא, כל שורת לוג וכל הגבלת קצב (rate limit) של התחברות יראו את 127.0.0.1, כלומר את ה-proxy עצמו; המשמעות היא שכישלונות של תוקף אחד ייספרו כנגד כל המשתמשים במופע. הגדירו גם את DOMAIN לכתובת ה-https האמיתית, כיוון ש-Vaultwarden בונה ממנה קישורים להזמנות ולאיפוס סיסמה, ומפתחות אבטחה מסוג WebAuthn קשורים למקור (origin) הזה.
פרט אחד שאנשים מפספסים: חיבור ה-WebSocket מעביר את ה-session token במחרוזת השאילתה (query string), בתור /notifications/hub?access_token=[JWT]. זה מגיע ללוג הגישה של ה-proxy שלכם בטקסט גלוי. בצעו redaction לפרמטר access_token בפורמט הלוג, או ודאו שהלוגים הללו לא נשלחים לשום מקום שאינו בשליטתכם.
חסימת מתקפות brute force בנקודת הקצה של ההתחברות
מגבלות קצב (rate limits) מופעלות כברירת מחדל (LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). הן מאטות תוקף, אך אינן עוצרות אותו. fail2ban עושה זאת, אך 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 =ואת ה-jail לתוך /etc/fail2ban/jail.d/vaultwarden.local:
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
banaction = %(banaction_allports)s
logpath = /vw-data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400אם השארתם את דף הניהול, הוסיפו jail שני שה-failregex שלו הוא ^.*Invalid admin token\. IP: <ADDR>.*$, כיוון שכשלים בניהול נרשמים עם הודעה שונה ופילטר ההתחברות לעולם לא יראה אותם. לאחר מכן בדקו את עבודתכם:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwardenjail תקין מציג את קובץ הלוג שלכם תחת File list ומדווח על Currently failed: 0. הקישו סיסמה שגויה שלוש פעמים מרשת אחרת והמונה יעלה, ולאחר מכן הכתובת תופיע תחת Banned IP list. אם המונה לעולם לא זז, הסיבה הרגילה היא logpath: זה חייב להיות הנתיב של הקובץ על המארח (host), ולא הנתיב /data/... בתוך המכולה. הסיבה הנפוצה השנייה היא X-Real-IP חסר, מה שגורם לכל חסימה לכוון ל-proxy שלכם. שאר ההגדרה, כולל ה-jail של SSH שאתם כבר אמורים להריץ, נמצא ב-מדריך fail2ban עבור Ubuntu 24.04.
סיסמת האב היא עדיין המפתח לכל המערכת
הצפנה בצד הלקוח משמעותה שסיסמת האב היא המפתח. סיסמת אב קצרה במופע (instance) שאת מסד הנתונים שלו תוקף העתיק, אינה מוגנת על ידי אף אחד מהצעדים במדריך זה, כיוון שהתוקף יתקוף את העותק במצב לא מקוון (offline) ובקצב שחומרת המחשב שלו מאפשרת. שום הגדרת שרת לא מגיעה למחשב של התוקף.
PASSWORD_ITERATIONS=600000 הוא מספר האיטרציות של ה-KDF שניתן ללקוחות בעת יצירת חשבון חדש. חשבונות קיימים שומרים על הערך שבו נוצרו, לכן העלאת הערך לא תשנה דבר עבור משתמשים שנרשמו בשנה שעברה. עליהם לשנות זאת בעצמם בהגדרות האבטחה של ה-web vault, פעולה שמבצעת הצפנה מחדש של המפתח שלהם. עליכם ליידע אותם, כיוון ששום דבר בממשק לא יעשה זאת.
לאחר מכן, הפעילו אימות דו-שלבי (2FA) לכל חשבון. הוא אינו מגן על הטקסט המוצפן (ciphertext), כיוון שמפתח ה-vault נגזר מסיסמת האב בלבד. הוא כן מונע ממצב שבו סיסמה גנובה מספיקה כדי להתחבר ולסנכרן עותק. REQUIRE_DEVICE_EMAIL=true מוסיף שלב של אישור בדוא"ל בפעם הראשונה שחשבון מתחבר ממכשיר לא מוכר.
גיבויים הם המקום שבו כספות בניהול עצמי נכשלות
tar czf של תיקיית הנתונים, שנשמר בספריית הבית על אותו שרת VPS, מבטל את כל הצעדים שפורטו לעיל. ארכיון זה מכיל את db.sqlite3 עם טקסט מוצפן של כל משתמש, את rsa_key.pem שמאפשר זיוף של סשני התחברות, ואת config.json עם אסימון הניהול וסיסמת ה-SMTP בטקסט גלוי. גישת קריאה לקובץ בודד זה משמעותה גישה לקריאת הכספת כולה.
שני כללים מכסים זאת. הוציאו את הארכיון מהשרת. הצפינו אותו לפני שהוא עוזב את המערכת.
קיימת גם בעיית תקינות. העתקת db.sqlite3 באמצעות cp בזמן שהשירות פעיל עלולה להפיק קובץ שנמצא באמצע כתיבה ולא ייפתח, ולא תגלו זאת עד לשלב השחזור. השתמשו ב-snapshot המובנה של SQLite במקום זאת:
sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"צד השחזור, שהוא החצי שאף אחד לא בודק, מכוסה ב-מדריך הגיבוי והשחזור של Vaultwarden.
מה אתם מאבדים בהשוואה ל-Bitwarden בניהול עצמי
חשבון נפש כנה. השירות המנוהל של Bitwarden מופעל על ידי אנשים שתפקידם המלא הוא תפעולו, עם ביקורות צד-שלישי מפורסמות וצוות כוננות שזמין ב-3 לפנות בוקר. אירוח עצמי מחליף זאת בקצב עדכוני האבטחה שלכם.
Vaultwarden מפיצה תיקוני אבטחה כגרסאות רגילות. גרסה 1.37.0, ששוחררה ב-24 ביולי 2026, היא הגרסה העדכנית נכון לאוגוסט 2026, והערות השחרור שלה מבקשות מהמשתמשים לעדכן בהקדם האפשרי. מופע (instance) שהקמתם לפני שנה ושכחתם ממנו מריץ קוד בן שנה. התג latest לא עוזר בפני עצמו: מכולה (container) פעילה שומרת על האימג' איתו היא התחילה עד שתריצו docker compose pull ותיצרו אותה מחדש. הגדירו עדכונים אוטומטיים ב-Ubuntu עבור חבילות המערכת, וקבעו תזכורת ביומן שבאמת תקראו עבור עדכון המכולה.
המסקנה שקורא כנה צריך להסיק: הקריפטוגרפיה כאן היא התכנון של Bitwarden והיא עמידה, בעוד שהסיכון התפעולי עובר במלואו אליכם. אם תעדכנו את המערכת ותגבו אותה במקום אחר, מופע Vaultwarden על שרת VPS שבשליטתכם הוא מקום סביר לשמירת הסיסמאות שלכם. אם שני ההרגלים הללו לא יתקיימו, שלמו עבור השירות המנוהל והשקיעו את תשומת הלב שלכם במקום אחר. השוואה תכונה-מול-תכונה נמצאת ב-השוואה בין Vaultwarden ל-Bitwarden באירוח עצמי.
הקשחת המארח שמתחת למכולה
Vaultwarden הוא תהליך אחד על גבי שרת Linux, ומשתמש root על אותו שרת יכול לקרוא את /vw-data ללא קשר להגדרות היישום. הריצו את המכולה כמשתמש ללא הרשאות מיוחדות באמצעות user: "1000:1000" בקובץ ה-compose שלכם, ודאגו שהבעלות על תיקיית הנתונים תואמת לכך, ובצעו mount לכל מה שהמכולה אינה כותבת אליו כקריאה בלבד באמצעות :ro. לאחר מכן, נעלו את הדלת הראשית: הקשחת SSH בשרת VPS מכסה התחברות מבוססת מפתח בלבד וביטול אימות באמצעות סיסמה, מה שמונע את המתקפות השגרתיות שעוקפות את כל האמור לעיל.
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.