Self-Hosting a Password Manager with Vaultwarden on Kubernetes
Why I run my own password vault instead of trusting the cloud, and how Vaultwarden deploys on a single-node Talos Linux cluster with 96 MB of memory.
I don't trust my passwords to someone else's infrastructure. That's not paranoia. It's a professional opinion formed by spending years managing identity systems, PAM platforms, and watching breaches unfold from the inside. Bitwarden's cloud service is well-run, but the only password vault I fully trust is the one I operate myself.
Vaultwarden is a community-built, Rust-based server compatible with all Bitwarden clients. It runs on a fraction of the resources the official Bitwarden server requires, which makes it perfect for a homelab. Here's how I deployed it on my single-node Talos Linux Kubernetes cluster.
Why Vaultwarden Over Bitwarden
The official Bitwarden self-hosted server is a beast: multiple containers, an MSSQL database, and resource requirements that would swallow half my 15.6 GB node. Vaultwarden replaces all of that with a single binary backed by SQLite. It supports the full Bitwarden API surface: browser extensions, mobile apps, desktop clients, CLI, organizations, Send, attachments. Everything I actually use.
The tradeoff is that Vaultwarden is a third-party project, not officially supported by Bitwarden. If that bothers you, run the official server. For a homelab where I control the entire stack, it's the right choice.
The Deployment
The full deployment is straightforward: a Deployment, Service, Ingress, PVC, and NetworkPolicies. No Helm chart, no operator, no database server. Here's the core:
apiVersion: apps/v1
kind: Deployment
metadata:
name: vaultwarden
namespace: vaultwarden
spec:
replicas: 1
selector:
matchLabels:
app: vaultwarden
template:
metadata:
labels:
app: vaultwarden
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: vaultwarden
image: vaultwarden/server:1.33.2
ports:
- containerPort: 80
env:
- name: DOMAIN
value: "https://vault.naviauxlab.com"
- name: SIGNUPS_ALLOWED
value: "false"
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
cpu: 200m
memory: 96Mi
livenessProbe:
httpGet:
path: /alive
port: 80
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /alive
port: 80
initialDelaySeconds: 5
periodSeconds: 5
securityContext:
capabilities:
drop: ["NET_RAW"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: vaultwarden-dataA few things worth calling out:
- 96 MB memory limit: Vaultwarden is written in Rust. It actually runs at around 64 MB. This is the lightest app in my cluster by a wide margin.
SIGNUPS_ALLOWED: false: The single most important environment variable. Without this, anyone who finds your instance can create an account. I created my account first, then set this to false.DOMAIN: Must match the URL you access Vaultwarden through. The Bitwarden clients use this for WebSocket connections and email verification links.- Pinned image version:
1.33.2, notlatest. Renovate handles updates via automated PRs: patch versions auto-merge, majors require manual review.
Storage
Vaultwarden stores everything under /data: the SQLite database, file attachments, RSA keys, and icon cache. A 1 GB Longhorn PVC is more than enough:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: vaultwarden-data
namespace: vaultwarden
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1GiSQLite is the right database for this. My vault has one user. I don't need PostgreSQL, connection pooling, or any of that complexity. The entire database is a single file that Longhorn snapshots can back up atomically.
Ingress and TLS
Vaultwarden gets the standard dual-host ingress pattern (one for LAN access, one for Cloudflare Tunnel):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vaultwarden
namespace: vaultwarden
annotations:
cert-manager.io/cluster-issuer: selfsigned
traefik.ingress.kubernetes.io/router.middlewares: auth-security-headers@kubernetescrd
spec:
rules:
- host: vault.naviauxlab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vaultwarden
port:
number: 80
- host: vault.homelab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vaultwarden
port:
number: 80
tls:
- hosts:
- vault.homelab.local
secretName: vaultwarden-tlsNotice there's no Authentik forward-auth middleware here. A password manager handles its own authentication. Adding SSO in front would create a circular dependency (need the password manager to log into the SSO to access the password manager). The only middleware is security-headers.
Network Policies
This is where the security posture gets serious. Every namespace in my cluster starts with default-deny on both ingress and egress:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: vaultwarden
spec:
podSelector: {}
policyTypes:
- Ingress
- EgressThen I explicitly allow only the traffic that needs to flow:
- Ingress from Traefik on TCP:80 (the ingress controller needs to reach Vaultwarden)
- Ingress from monitoring on TCP:80 (Prometheus metrics scraping)
- Ingress from Uptime Kuma on TCP:80 (health checks)
- Ingress from Homepage on TCP:80 (dashboard widget)
- Egress to kube-dns on TCP/UDP:53 (DNS resolution)
That's it. Vaultwarden can't talk to the internet, can't reach other apps, can't do anything except serve requests from Traefik and answer DNS queries. For a password vault, this is exactly the attack surface I want: minimal.
What I'd Do Differently
The one thing that bugs me: Vaultwarden runs as root inside the container. The upstream image doesn't support runAsNonRoot without custom image builds. I drop NET_RAW capabilities and set a RuntimeDefault seccomp profile, which limits the blast radius, but running as root in a container holding passwords still makes me twitch. A future project is building a custom image with a non-root user.
I also don't have SMTP configured yet. That means no email-based 2FA recovery and no breach report notifications. The egress network policy would need to open TCP:587 before SMTP would work anyway. Right now Vaultwarden literally cannot reach the internet. That's a deliberate tradeoff: maximum network isolation now, email notifications later.
The Result
Vaultwarden has been running for weeks with zero issues. It uses 64 MB of memory, handles Bitwarden browser extension syncs instantly, and survives pod restarts without data loss thanks to the Longhorn PVC. The Bitwarden mobile app connects through Cloudflare Tunnel with no configuration beyond pointing it at vault.naviauxlab.com.
For a password manager, boring is the goal. This is boring in the best way.