A comprehensive guide to deploying and securing your own password manager on Kubernetes
Table of Contents
- Introduction
- What is Vaultwarden?
- Why Self-Host Your Password Manager?
- Prerequisites
- Architecture Overview
- Step 1: Database Setup
- Step 2: Secret Management
- Step 3: Kubernetes Deployment
- Step 4: Security Hardening
- Step 5: Network Policies
- Step 6: Monitoring & Maintenance
- Testing Your Deployment
- Conclusion
Introduction
In an era where data breaches hit record highs with over 2,300 incidents in 2024 alone, and 81% of these breaches are linked to weak or reused passwords, password management has never been more critical. While cloud-based password managers are convenient, they come with inherent risks: you’re trusting a third party with the keys to your digital kingdom.
This is where self-hosting comes in. By deploying your own password manager, you gain complete control over your sensitive data, eliminate third-party trust requirements, and can implement security measures tailored to your specific needs.
In this comprehensive guide, I’ll walk you through deploying Vaultwardenβa lightweight, unofficial Bitwarden-compatible serverβon Kubernetes with production-grade security hardening. This isn’t just a basic deployment; we’ll implement pod security contexts, network policies, rate limiting, comprehensive secret management, and monitoring to create a truly production-ready system.
What is Vaultwarden?
Vaultwarden (formerly known as bitwarden_rs) is an unofficial, open-source reimplementation of the Bitwarden server API written in Rust. It’s designed to be lightweight, resource-efficient, and easy to self-host while maintaining full compatibility with all official Bitwarden clients.
Key Features
Vaultwarden vs. Official Bitwarden
| Feature | Vaultwarden | Official Bitwarden |
|---|
| Resource Usage | ~50MB RAM, SQLite/PostgreSQL | 2GB+ RAM, MSSQL required |
| Premium Features | Free | Paid ($10-40/year) |
| Setup Complexity | Low | High (multiple services) |
| Support | Community | Official |
| Audit | Community-driven | Third-party audited |
| Client Compatibility | 100% | 100% (official) |
Why Self-Host Your Password Manager?
1. Complete Data Control
With a self-hosted password manager, your data resides entirely on your own servers. You’re not trusting a third party, regardless of their security claims. This is particularly important for businesses handling sensitive client data or individuals with strict privacy requirements.
2. Enhanced Security Posture
Self-hosting allows you to:
- Implement your own security models and policies
- Place your password manager behind custom proxies and firewalls
- Control exactly who has access and from where
- Audit all access attempts and changes
- Implement custom authentication mechanisms
As noted by security experts, businesses can implement security measures that commercial password managers may not provide.
3. No Vendor Lock-In
Cloud services can change pricing, terms, or even shut down. Self-hosting means you’re never held hostage by a vendor’s business decisions.
4. Compliance & Regulatory Requirements
Many industries (healthcare, finance, government) have strict data residency and handling requirements. Self-hosting ensures you meet these obligations.
5. Cost Savings
For teams, Bitwarden premium can cost $40/year per user. A self-hosted Vaultwarden instance can serve unlimited users at just the cost of your infrastructure.
The Trade-offs
Self-hosting isn’t without responsibilities:
- You must handle backups, updates, and security patches
- You’re responsible for availability and disaster recovery
- You need technical expertise to maintain the system
- You won’t have official vendor support
However, with proper setup (which this guide provides), these responsibilities become manageable routine maintenance tasks.
Prerequisites
Before we begin, ensure you have:
Infrastructure Requirements
- Kubernetes Cluster: v1.24+ (this guide uses a standard K8s cluster)
- kubectl: Configured and authenticated to your cluster
- Helmfile: For simplified Helm chart management
- PostgreSQL Database: Running in your cluster or externally accessible
- Ingress Controller: NGINX Ingress Controller installed
- Cert-Manager: For automatic TLS certificate management
- DNS: A domain name with DNS configured (e.g.,
vault.yourdomain.com)
Secret Management
- HashiCorp Vault: For secure secret storage
- External Secrets Operator: To sync secrets from Vault to Kubernetes
Knowledge Requirements
- Basic Kubernetes concepts (pods, services, deployments)
- Understanding of Helm charts
- Familiarity with YAML configuration
- Basic command-line proficiency
Estimated Time
- Initial setup: 1-2 hours
- Security hardening: 30-60 minutes
- Testing and validation: 30 minutes
Architecture Overview
Our production-ready Vaultwarden deployment consists of several components:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Internet β
βββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
β HTTPS (TLS 1.2+)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β NGINX Ingress Controller β
β - TLS Termination β
β - Security Headers (CSP, HSTS, X-Frame-Options) β
β - Rate Limiting (10 req/sec) β
βββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β Vaultwarden Pod β
β - Non-root user (UID 1000) β
β - Dropped capabilities β
β - Rate limiting β
β - Extended logging β
ββββββββββββ¬βββββββββββββββββββ
β
βββββββββββ΄ββββββββββ
β β
βΌ βΌ
βββββββββββββββββββ ββββββββββββββββββββ
β PostgreSQL DB β β External SMTP β
β - Encrypted β β - Email β
β connection β β notifications β
β - Connection β β - 2FA codes β
β pooling β ββββββββββββββββββββ
βββββββββββββββββββ
β²
β
ββββββββββ΄βββββββββββ
β Vault (Secrets) β
β β β
β External Secrets β
β Operator β
βββββββββββββββββββββ
|
Component Breakdown
- Ingress Layer: Handles TLS termination, security headers, and initial rate limiting
- Vaultwarden Application: Runs as non-root with minimal privileges
- PostgreSQL Database: Persistent storage for encrypted vault data
- Vault + External Secrets: Secure secret management pipeline
- Network Policies: Restrict pod-to-pod communication
Step 1: Database Setup
Vaultwarden requires a database to store encrypted vault data. While it supports SQLite, PostgreSQL is strongly recommended for production due to better concurrency, backup capabilities, and reliability.
1.1 Create Database and User
Connect to your PostgreSQL instance:
1
2
3
4
5
| # If PostgreSQL is running in Kubernetes
kubectl exec -it postgres-app-0 -n postgres -- psql -U postgres
# Or connect from your local machine
psql -h your-postgres-host -U postgres
|
Create the database and dedicated user:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| -- Create a dedicated user for vaultwarden
CREATE USER vaultwarden WITH ENCRYPTED PASSWORD 'REPLACE_WITH_STRONG_PASSWORD';
-- Create the database owned by vaultwarden user
CREATE DATABASE vaultwarden WITH OWNER vaultwarden;
-- Grant all privileges on the database
GRANT ALL PRIVILEGES ON DATABASE vaultwarden TO vaultwarden;
-- Connect to the vaultwarden database
\c vaultwarden postgres
-- Grant schema privileges (important for table creation)
GRANT ALL ON SCHEMA public TO vaultwarden;
-- Grant default privileges for future objects
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO vaultwarden;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO vaultwarden;
|
1.2 Verify Database Setup
1
2
3
4
5
6
7
8
9
10
| -- Verify user was created
\du vaultwarden
-- Verify database exists and has correct owner
\l vaultwarden
-- Expected output:
-- Name | Owner | Encoding | Collate | Ctype | Access privileges
-- -------------+-------------+----------+---------+-------+-------------------
-- vaultwarden | vaultwarden | UTF8 | ... | ... | =Tc/vaultwarden
|
1.3 Construct Database URL
Your database connection string will look like:
1
| postgresql://vaultwarden:<PASSWORD>@<HOST>:<PORT>/vaultwarden
|
For example:
Security Note: Never commit this URL to version control. We’ll store it securely in Vault in the next step.
Step 2: Secret Management
Proper secret management is critical for security. We’ll use HashiCorp Vault as our source of truth, with External Secrets Operator syncing secrets to Kubernetes.
2.1 Generate Required Secrets
First, generate all necessary secrets:
1
2
3
4
5
6
7
8
9
10
11
| # Generate admin token (used to access admin panel)
# This should be a long, random string
ADMIN_TOKEN=$(openssl rand -base64 48)
echo "Admin Token: $ADMIN_TOKEN"
# Save this in a secure location!
# Generate database password (if you haven't already)
DB_PASSWORD=$(openssl rand -base64 32)
# You'll also need SMTP credentials from your email provider
# For Gmail, use an App Password: https://myaccount.google.com/apppasswords
|
2.2 Store Secrets in Vault
Store all credentials in HashiCorp Vault:
1
2
3
4
5
6
7
8
9
10
11
12
| # Ensure you're authenticated to Vault
vault login
# Store all vaultwarden credentials in a single secret
vault kv put kv/infra/vaultwarden-credentials \
db-url='postgresql://vaultwarden:[email protected]:5432/vaultwarden' \
admin-token="$ADMIN_TOKEN" \
smtp-username='[email protected]' \
smtp-password='your-smtp-app-password'
# Verify the secret was stored
vault kv get kv/infra/vaultwarden-credentials
|
The External Secrets Operator will automatically sync these secrets to Kubernetes:
1
2
3
4
5
6
7
| # This will be part of our Helm values (shown in next step)
externalSecrets:
- name: vaultwarden-credentials
refreshInterval: 5m # Sync every 5 minutes
secretStoreRefName: vault-backend
targetName: vaultwarden-credentials
dataKey: infra/vaultwarden-credentials
|
This creates a Kubernetes Secret named vaultwarden-credentials in the vaultwarden namespace, automatically synced from Vault.
Step 3: Kubernetes Deployment
Now we’ll deploy Vaultwarden using Helmfile and a custom Helm chart.
3.1 Directory Structure
Create the following directory structure:
1
2
3
4
5
| k8s/releases/vaultwarden/
βββ helmfile.yaml
βββ values.yml
βββ network-policy.yaml (we'll create this in Step 5)
βββ README.md
|
3.2 Helmfile Configuration
Create helmfile.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| helmDefaults:
createNamespace: true
timeout: 300
wait: false
repositories:
- name: citizix
url: https://etowett.github.io/helm-charts
releases:
- name: vaultwarden
namespace: vaultwarden
chart: citizix/app
version: "1.3.1"
values:
- ./values.yml
|
3.3 Helm Values Configuration
Create values.yml with production-ready configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
| replicaCount: 1
image:
repository: vaultwarden/server
pullPolicy: IfNotPresent
tag: "1.35.2-alpine"
serviceAccount:
create: true
# ============================================
# SECURITY CONTEXT - Run as non-root user
# ============================================
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false # Vaultwarden needs /data write access
runAsNonRoot: true
runAsUser: 1000
# ============================================
# SERVICE CONFIGURATION
# ============================================
service:
type: ClusterIP
name: http
port: 80
targetPort: 8080 # Non-root port
# ============================================
# HEALTH CHECKS
# ============================================
livenessProbe:
path: /alive
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
path: /alive
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
# ============================================
# RESOURCE LIMITS
# ============================================
resources:
limits:
cpu: 1000m
memory: 1024Mi
requests:
cpu: 100m
memory: 128Mi
# ============================================
# INGRESS WITH SECURITY HARDENING
# ============================================
ingress:
enabled: true
className: nginx
annotations:
# TLS Certificate
cert-manager.io/cluster-issuer: letsencrypt-prod-issuer
# Force HTTPS
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-protocols: "TLSv1.2 TLSv1.3"
nginx.ingress.kubernetes.io/ssl-ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
# Security Headers
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "X-Frame-Options: DENY";
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "X-XSS-Protection: 1; mode=block";
more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
more_set_headers "Permissions-Policy: geolocation=(), microphone=(), camera=()";
more_set_headers "Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
# Rate Limiting
nginx.ingress.kubernetes.io/limit-rps: "10"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "2"
# Timeouts and Body Size
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
hosts:
- host: vault.yourdomain.com # CHANGE THIS
paths:
- path: /
pathType: ImplementationSpecific
tls:
- secretName: vaultwarden-tls
hosts:
- vault.yourdomain.com # CHANGE THIS
# ============================================
# EXTERNAL SECRETS CONFIGURATION
# ============================================
externalSecrets:
- name: vaultwarden-credentials
refreshInterval: 5m
secretStoreRefName: vault-backend
targetName: vaultwarden-credentials
dataKey: infra/vaultwarden-credentials
# ============================================
# VAULTWARDEN ENVIRONMENT VARIABLES
# ============================================
env:
# Domain Configuration
DOMAIN: "https://vault.yourdomain.com" # CHANGE THIS
# Signup Controls - Disable public signups
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
INVITATION_ORG_NAME: "Your Organization"
SIGNUPS_DOMAINS_WHITELIST: "yourdomain.com" # CHANGE THIS
# Organization Features
ORG_EVENTS_ENABLED: "true"
ORG_GROUPS_ENABLED: "true"
# Password Security
SHOW_PASSWORD_HINT: "false"
PASSWORD_HINTS_ALLOWED: "false"
PASSWORD_ITERATIONS: "600000" # 6x more secure than default
# Login Rate Limiting - Prevent brute force
LOGIN_RATELIMIT_SECONDS: "60"
LOGIN_RATELIMIT_MAX_BURST: "10"
ADMIN_RATELIMIT_SECONDS: "300"
ADMIN_RATELIMIT_MAX_BURST: "3"
# Session Security
EXTENDED_LOGGING: "true"
LOG_LEVEL: "info"
REQUIRE_DEVICE_EMAIL: "true" # Email verification for new devices
# Two-Factor Authentication
DISABLE_2FA_REMEMBER: "false"
# Emergency Access
EMERGENCY_ACCESS_ALLOWED: "true"
# File Upload Limits
ATTACHMENT_LIMIT: "52428800" # 50MB
SEND_ATTACHMENT_SIZE_LIMIT: "104857600" # 100MB
TRASH_AUTO_DELETE_DAYS: "30"
# Icon Service Security - Prevent SSRF attacks
DISABLE_ICON_DOWNLOAD: "false"
ICON_CACHE_TTL: "2592000"
ICON_DOWNLOAD_TIMEOUT: "10"
ICON_BLACKLIST_NON_GLOBAL_IPS: "true"
# Database Settings
DB_CONNECTION_RETRIES: "15"
DATABASE_MAX_CONNS: "10"
# Web Server Configuration
ROCKET_PORT: "8080"
ROCKET_WORKERS: "10"
# SMTP Configuration - Change these
SMTP_HOST: "smtp.gmail.com"
SMTP_FROM: "[email protected]"
SMTP_FROM_NAME: "Your Organization Vault"
SMTP_TIMEOUT: "15"
SMTP_SECURITY: "starttls"
SMTP_PORT: "587"
SMTP_DEBUG: "false"
SMTP_ACCEPT_INVALID_CERTS: "false"
SMTP_ACCEPT_INVALID_HOSTNAMES: "false"
# ============================================
# SECRET ENVIRONMENT VARIABLES
# ============================================
secretEnv:
DATABASE_URL:
name: vaultwarden-credentials
key: db-url
ADMIN_TOKEN:
name: vaultwarden-credentials
key: admin-token
SMTP_USERNAME:
name: vaultwarden-credentials
key: smtp-username
SMTP_PASSWORD:
name: vaultwarden-credentials
key: smtp-password
|
3.4 Deploy Vaultwarden
Deploy the application:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Navigate to the vaultwarden directory
cd k8s/releases/vaultwarden
# Deploy using helmfile
helmfile apply
# Watch the deployment
kubectl rollout status deployment/vaultwarden-app -n vaultwarden
# Check pod status
kubectl get pods -n vaultwarden
# View logs
kubectl logs -f -n vaultwarden -l app.kubernetes.io/name=vaultwarden
|
3.5 Verify Deployment
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Check if the pod is running
kubectl get pods -n vaultwarden
# Expected output:
# NAME READY STATUS RESTARTS AGE
# vaultwarden-app-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
# Check ingress
kubectl get ingress -n vaultwarden
# Test basic connectivity
curl -I https://vault.yourdomain.com
# Expected: HTTP/2 200 with security headers
|
Step 4: Security Hardening
Now let’s implement additional security hardening beyond the basic deployment.
4.1 Hash the Admin Token
For additional security, hash your admin token using Argon2:
1
2
3
4
5
6
7
8
| # Get the pod name
POD_NAME=$(kubectl get pod -n vaultwarden -l app.kubernetes.io/name=vaultwarden -o jsonpath='{.items[0].metadata.name}')
# Run the hash command
kubectl exec -it $POD_NAME -n vaultwarden -- /vaultwarden hash
# Enter your admin token when prompted
# Copy the resulting hash (starts with $argon2id$)
|
Update Vault with the hashed token:
1
2
3
4
5
6
7
8
9
10
| # Update the admin token in Vault
vault kv patch kv/infra/vaultwarden-credentials \
admin-token='$argon2id$v=19$m=65540,t=3,p=4$...' # Your hash here
# Force External Secrets to sync
kubectl annotate externalsecret vaultwarden-credentials -n vaultwarden \
force-sync=$(date +%s) --overwrite
# Restart vaultwarden to pick up the new token
kubectl rollout restart deployment/vaultwarden-app -n vaultwarden
|
Important: When accessing the admin panel, you’ll still use the original plain text token as the password, not the hash!
4.2 Enable Admin Panel IP Whitelisting (Optional)
For maximum security, restrict admin panel access to specific IP addresses:
Add this to your ingress annotations in values.yml:
1
2
3
4
5
6
| nginx.ingress.kubernetes.io/server-snippet: |
location /admin {
allow 203.0.113.0/24; # Your office IP range
allow 198.51.100.50; # Your VPN IP
deny all;
}
|
Set up automated database backups:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| # Create a CronJob for daily backups
cat <<EOF | kubectl apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
name: vaultwarden-backup
namespace: postgres
spec:
schedule: "0 2 * * *" # Daily at 2 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: postgres:15-alpine
command:
- /bin/sh
- -c
- |
pg_dump -h postgres-app -U postgres vaultwarden | \
gzip > /backups/vaultwarden-\$(date +\%Y\%m\%d-\%H\%M\%S).sql.gz
# Upload to S3 or other backup location
# aws s3 cp /backups/vaultwarden-*.sql.gz s3://your-bucket/
volumeMounts:
- name: backups
mountPath: /backups
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-credentials
key: password
volumes:
- name: backups
persistentVolumeClaim:
claimName: postgres-backups
restartPolicy: OnFailure
EOF
|
Step 5: Network Policies
Implement Kubernetes NetworkPolicies to restrict pod-to-pod communication using the principle of least privilege.
5.1 Label Namespaces
First, ensure your namespaces have the correct labels:
1
2
3
4
5
6
7
8
| # Label the ingress namespace
kubectl label namespace ingress-nginx name=ingress-nginx --overwrite
# Label the postgres namespace
kubectl label namespace postgres name=postgres --overwrite
# If using monitoring
kubectl label namespace monitoring name=monitoring --overwrite
|
5.2 Create Network Policy
Create network-policy.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: vaultwarden-netpol
namespace: vaultwarden
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: vaultwarden
policyTypes:
- Ingress
- Egress
# INGRESS RULES - Who can connect TO vaultwarden
ingress:
# Allow traffic from ingress controller
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- protocol: TCP
port: 8080
# Allow traffic from monitoring (optional)
- from:
- namespaceSelector:
matchLabels:
name: monitoring
ports:
- protocol: TCP
port: 8080
# EGRESS RULES - Where vaultwarden can connect TO
egress:
# Allow DNS resolution
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Allow PostgreSQL database access
- to:
- namespaceSelector:
matchLabels:
name: postgres
ports:
- protocol: TCP
port: 5432
# Allow SMTP for email notifications
- to:
- namespaceSelector: {}
ports:
- protocol: TCP
port: 587 # STARTTLS
- protocol: TCP
port: 465 # SMTPS
# Allow HTTPS for icon downloads
- to:
- namespaceSelector: {}
ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 80
|
5.3 Apply Network Policy
1
2
3
4
5
6
7
8
9
| # Apply the network policy
kubectl apply -f network-policy.yaml
# Verify it was created
kubectl get networkpolicy -n vaultwarden
# Test that vaultwarden still works
curl -I https://vault.yourdomain.com
# Should still return 200 OK
|
5.4 Verify Network Restrictions
Test that the network policy is working:
1
2
3
4
5
6
7
| # This should FAIL (vaultwarden can't access other services)
kubectl run test-pod --rm -i --tty --image=busybox -- \
wget -O- http://vaultwarden-app.vaultwarden.svc.cluster.local
# This should SUCCEED (ingress can access vaultwarden)
kubectl run test-pod -n ingress-nginx --rm -i --tty --image=busybox -- \
wget -O- http://vaultwarden-app.vaultwarden.svc.cluster.local:8080/alive
|
Step 6: Monitoring & Maintenance
6.1 Set Up Logging
Monitor your vaultwarden logs for security events:
1
2
3
4
5
6
7
8
9
| # View live logs
kubectl logs -f -n vaultwarden -l app.kubernetes.io/name=vaultwarden
# Check for failed login attempts
kubectl logs -n vaultwarden -l app.kubernetes.io/name=vaultwarden --since=24h | \
grep -iE "error|failed|unauthorized|rate.?limit"
# Save logs to file for analysis
kubectl logs -n vaultwarden -l app.kubernetes.io/name=vaultwarden --since=7d > vaultwarden-audit.log
|
6.2 Set Up Prometheus Monitoring (Optional)
If using Prometheus Operator, add ServiceMonitor to your values.yml:
1
2
3
4
5
6
| serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
labels:
release: prometheus
|
Create alert rules:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: vaultwarden-alerts
namespace: vaultwarden
spec:
groups:
- name: vaultwarden
interval: 30s
rules:
- alert: VaultwardenDown
expr: up{job="vaultwarden"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: "Vaultwarden is down"
- alert: VaultwardenHighMemory
expr: container_memory_usage_bytes{pod=~"vaultwarden.*"} / container_spec_memory_limit_bytes{pod=~"vaultwarden.*"} > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "Vaultwarden high memory usage"
|
6.3 Regular Maintenance Tasks
Create a maintenance schedule:
Weekly:
1
2
3
4
5
6
7
8
9
10
| # Review security logs
kubectl logs -n vaultwarden -l app.kubernetes.io/name=vaultwarden --since=7d | \
grep -iE "error|failed|unauthorized" > weekly-audit.log
# Check resource usage
kubectl top pods -n vaultwarden
# Verify backups completed
kubectl get cronjob -n postgres
kubectl get jobs -n postgres
|
Monthly:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Update vaultwarden to latest version
# Edit values.yml and change image tag
# image:
# tag: "1.36.0-alpine" # Update to latest
# Apply update
helmfile apply
# Monitor rollout
kubectl rollout status deployment/vaultwarden-app -n vaultwarden
# Test functionality after update
curl -I https://vault.yourdomain.com
|
Quarterly:
1
2
3
4
5
6
7
8
9
10
| # Rotate admin token
NEW_TOKEN=$(openssl rand -base64 48)
vault kv patch kv/infra/vaultwarden-credentials admin-token="$NEW_TOKEN"
kubectl rollout restart deployment/vaultwarden-app -n vaultwarden
# Security scan
trivy image vaultwarden/server:1.35.2-alpine
# Test disaster recovery
# (Restore backup to test environment)
|
Testing Your Deployment
7.1 Functional Tests
Test basic functionality:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # 1. Test web access
curl -I https://vault.yourdomain.com
# Expected: HTTP/2 200 with security headers
# 2. Test admin panel access
curl -I https://vault.yourdomain.com/admin
# Expected: HTTP/2 200 (will show login page)
# 3. Test security headers
curl -I https://vault.yourdomain.com | grep -E "X-Frame|X-Content|CSP"
# Expected: See security headers
# 4. Test rate limiting
for i in {1..15}; do
curl -w "%{http_code}\n" -o /dev/null -s https://vault.yourdomain.com/api/accounts
done
# Expected: Should see 429 (Too Many Requests) after ~10 requests
|
7.2 Client Setup
Now set up Bitwarden clients to connect to your Vaultwarden instance:
Desktop/Mobile:
- Before logging in, tap the settings gear icon
- Enter your server URL:
https://vault.yourdomain.com - Create an account or login
Browser Extension:
- Install the Bitwarden browser extension
- Click the extension icon β Settings β Gear icon
- Set server URL:
https://vault.yourdomain.com - Login or create account
7.3 Admin Panel Configuration
Access the admin panel at https://vault.yourdomain.com/admin:
- Enter your admin token (the original plain text version)
- Review and configure:
- User management
- Organization settings
- Email settings (test email delivery)
- 2FA enforcement policies
Important: Enable 2FA requirement for all users:
- Users β Select user β Edit β Require 2FA
7.4 Create Test Vault Items
- Login to the web vault
- Create a test login item
- Verify it syncs to your other devices
- Test autofill in browser
- Test the Send feature (secure sharing)
Troubleshooting Common Issues
Pod Won’t Start
1
2
3
4
5
6
7
8
9
10
| # Check pod status
kubectl describe pod -n vaultwarden -l app.kubernetes.io/name=vaultwarden
# Check logs
kubectl logs -n vaultwarden -l app.kubernetes.io/name=vaultwarden --previous
# Common issues:
# - Database connection failed: Check DATABASE_URL secret
# - Permission denied: Check pod security context
# - Admin token invalid: Verify ADMIN_TOKEN in secret
|
Can’t Access Admin Panel
1
2
3
4
5
6
7
| # Verify admin token
vault kv get -field=admin-token kv/infra/vaultwarden-credentials
# Check if secret synced to Kubernetes
kubectl get secret vaultwarden-credentials -n vaultwarden -o yaml
# Verify you're using the PLAIN TEXT token, not the hash
|
Email Not Sending
1
2
3
4
5
6
7
8
9
| # Check SMTP logs
kubectl logs -n vaultwarden -l app.kubernetes.io/name=vaultwarden | grep -i smtp
# Test SMTP connectivity from pod
kubectl exec -it vaultwarden-app-xxxx-xxx -n vaultwarden -- sh
nc -vz smtp.gmail.com 587
# For Gmail: Ensure you're using an App Password, not your regular password
# Generate at: https://myaccount.google.com/apppasswords
|
Rate Limiting Too Aggressive
If legitimate users are getting rate limited:
1
2
3
4
5
6
| # Edit values.yml and increase limits:
LOGIN_RATELIMIT_MAX_BURST: "20" # Increased from 10
ADMIN_RATELIMIT_MAX_BURST: "5" # Increased from 3
# Redeploy
helmfile apply
|
Network Policy Blocking Traffic
1
2
3
4
5
6
| # Temporarily remove to test
kubectl delete networkpolicy vaultwarden-netpol -n vaultwarden
# If that fixes it, adjust the policy
# Check namespace labels:
kubectl get namespace --show-labels
|
Security Best Practices Summary
Here’s a checklist of security measures we’ve implemented:
- β
Pod Security: Non-root user, dropped capabilities, seccomp profile
- β
Network Segmentation: NetworkPolicy restricting traffic
- β
Secret Management: Vault + External Secrets Operator
- β
TLS Encryption: TLS 1.2+ with strong ciphers
- β
Security Headers: CSP, HSTS, X-Frame-Options, etc.
- β
Rate Limiting: Both at ingress and application level
- β
Password Security: 600,000 iterations (6x default)
- β
Admin Token: Hashed with Argon2
- β
SSRF Protection: Icon service secured
- β
Audit Logging: Extended logging enabled
- β
Device Verification: Email required for new devices
- β
Regular Backups: Automated daily backups
- β
Resource Limits: Proper CPU/memory constraints
- β
Health Checks: Liveness and readiness probes
Conclusion
Congratulations! You now have a production-ready, security-hardened Vaultwarden deployment running on Kubernetes. This setup provides:
- Complete data ownership: Your passwords live entirely on your infrastructure
- Enterprise-grade security: Multiple layers of defense
- High availability: Kubernetes handles pod restarts and health monitoring
- Scalability: Easy to scale or migrate as your needs grow
- Cost efficiency: Free alternative to premium password managers
Key Takeaways
- Self-hosting gives you control, but with it comes responsibility for maintenance, backups, and security
- Security is layered: We implemented defense in depth with pod security, network policies, rate limiting, and more
- Secret management matters: Using Vault + External Secrets keeps sensitive data secure
- Monitoring is critical: Regular log reviews and automated backups prevent disasters
- Keep it updated: Regular updates and security scans are essential
Next Steps
- Share with your team: Set up accounts and enforce 2FA
- Import existing passwords: Use Bitwarden’s import tool to migrate from other password managers
- Set up monitoring: Implement Prometheus alerts for proactive monitoring
- Test disaster recovery: Practice restoring from backup in a test environment
- Stay updated: Subscribe to Vaultwarden releases for security updates
Resources
About This Guide
This guide is based on a real production deployment running on Kubernetes, serving a team of developers and handling thousands of vault items. The configuration has been battle-tested and refined over months of operation.
If you found this guide helpful, consider sharing it with others who might benefit from self-hosting their password manager. Questions or improvements? Feel free to reach out!
Sources
Published: January 2026
Last Updated: January 18, 2026