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.

Self-Hosting a Password Manager with Vaultwarden on Kubernetes

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-data

A 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, not latest. 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: 1Gi

SQLite 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-tls

Notice 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
    - Egress

Then 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.