Vaultwarden is an implementation of the Bitwarden API in Rust. It’s fast, lightweight, secure, and compatible with the official Bitwarden clients. It’s perfect for self-hosting a password manager for your family and a few friends. I’ve run it for over two years and it is rock solid. I support Bitwarden as well by buying a personal subscription and recommend it to my clients for their enterprise password management needs.

As this is part of the series of posts where I’m sharing how I roll out applications on Kubernetes using Ansible, I’m assuming that you are familiar with the previous post, starting with The Great Migration, which detail the underlying components and pieces that got us to this point.

Since this Ansible role uses a Kubernetes persistent volume, I’ll also remind you that I prefer to create the Longhorn volume directly in the Longhorn dashboard and reference it in the Kubernetes manifests. You could also let Longhorn provision the volume by removing that from the manifest.

A 10Gi volume called vaultwarden-vol is what I used. I’m just using the default sqlite database.

Vaultwarden role

These variable need to go in inventory/group_vars/k3s_cluster:

vaultwarden_namespace: vaultwarden
vaultwarden_image: vaultwarden/server:1.26.0
vaultwarden_hostname: vault.domain.tld
vaultwarden_vol_size: 10Gi
yubico_clientid: see Vaultwarden documentation
yubico_secretkey: see Vaultwarden documentation

roles/k3s_cluster/vaultwarden/tasks/main.yml:

- name: Vaultwarden Namespace
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/namespace.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Vaultwarden Volume
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/volume.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Vaultwarden Deployment
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/deployment.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Vaultwarden Secret
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/secret.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Vaultwarden Service
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/service.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Vaultwarden Ingress
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/ingress.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true

roles/k3s_cluster/vaultwarden/manifests/namespace.j2:

apiVersion: v1
kind: Namespace
metadata:
  name: {{ vaultwarden_namespace }}

roles/k3s_cluster/vaultwarden/manifests/volume.j2:

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: vaultwarden-vol-pv
  namespace: {{ vaultwarden_namespace }}
  labels:
    backup: daily
spec:
  capacity:
    storage: {{ vaultwarden_vol_size }}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: longhorn-static
  csi:
    driver: driver.longhorn.io
    fsType: ext4
    volumeAttributes:
      numberOfReplicas: "3"
      staleReplicaTimeout: "2880"
    volumeHandle: vaultwarden-vol
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: vaultwarden-vol-pvc
  namespace: {{ vaultwarden_namespace }}
spec:
  accessModes:
  - ReadWriteMany 
  storageClassName: longhorn-static
  volumeName: vaultwarden-vol-pv
  resources:
    requests:
      storage: {{ vaultwarden_vol_size }}

roles/k3s_cluster/vaultwarden/manifests/deployment.j2:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vaultwarden
  namespace: {{ vaultwarden_namespace }}
  labels:
    app: vaultwarden
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vaultwarden
  template:
    metadata:
      labels:
        app: vaultwarden
    spec:
      containers:
        - name: vaultwarden
          image: {{ vaultwarden_image }}
          env:
          - name: SIGNUPS_ALLOWED
            value: "true"
          - name: SMTP_FROM
            value: noreply@domain.tld
          - name: SMTP_HOST
            value: mail.domain.tld
          - name: SMTP_PORT
            value: "587"
          - name: SMTP_SECURITY
            value: "starttls"
          - name: WEBSOCKET_ENABLED
            value: "true"
          envFrom:
          - secretRef:
              name: vaultwarden-secret
              optional: false
          imagePullPolicy: IfNotPresent
          ports:
          - containerPort: 80
            name: bitwarden-www
            protocol: TCP
          - containerPort: 3012
            name: bitwarden-ws
            protocol: TCP
          volumeMounts:
            - name: vaultwarden-vol
              mountPath: /data
      volumes:
        - name: vaultwarden-vol
          persistentVolumeClaim:
            claimName: vaultwarden-vol-pvc

roles/k3s_cluster/vaultwarden/manifests/secret.j2:

apiVersion: v1
kind: Secret
metadata:
  name: vaultwarden-secret
  namespace: {{ vaultwarden_namespace }}
type: Opaque
stringData:
  SMTP_PASSWORD: "{{ smtp_password }}"
  SMTP_USERNAME: "{{ smtp_username }}"
  YUBICO_CLIENT_ID: "{{ yubico_clientid }}"
  YUBICO_SECRET_KEY: "{{ yubico_secretkey }}"

roles/k3s_cluster/vaultwarden/manifests/service.j2:

apiVersion: v1
kind: Service
metadata:
  name: vaultwarden
  namespace: {{ vaultwarden_namespace }}
spec:
  selector:
    app: vaultwarden
  ports:
  - name: http
    port: 80
  - name: ws
    port: 3012

roles/k3s_cluster/vaultwarden/manifests/ingress.j2:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: vaultwarden
  namespace: {{ vaultwarden_namespace }}
spec:
  entryPoints:
    - web
    - websecure
  routes:
    - match: Host(`{{ vaultwarden_hostname }}`)
      kind: Rule
      services:
        - name: vaultwarden
          port: 80
    - match: Host(`{{ vaultwarden_hostname }}`) && PathPrefix(`/notifications/hub`)
      kind: Rule
      services:
        - name: vaultwarden
          port: 3012
    - match: Host(`{{ vaultwarden_hostname }}`) && PathPrefix(`/notifications/hub/negotiate`)
      kind: Rule
      services:
        - name: vaultwarden
          port: 80

Vaultwarden Playbook

k3s-vaultwarden.yml:

---
- hosts: master[0]
  become: yes
  vars:
    ansible_python_interpreter: /usr/bin/python3
  remote_user: ansible
  pre_tasks:
    - name: Install Kubernetes Python module
      pip:
        name: kubernetes
    - name: Install Kubernetes-validate Python module
      pip:
        name: kubernetes-validate
  roles:
    - role: k3s_cluster/vaultwarden

Wrap up

Be sure to refer to the Vaultwarden configuration documentation to customize it to meet your needs. Most options can be configured through environment variables and it’s easy to add or modify the ones I’ve included in the Vaultwarden role. I recommend creating your initial account and then disabling sign ups. Registered users can still invite new users.