7 min read

Pretty damn secure self hosted Bitwarden

Pretty damn secure self hosted Bitwarden

Every year I spend an afternoon reading through my credit card statement to see whether I've accidentally forgotten to unsubscribe from something.

This year was no different, and on my travels through the statement, I stumbled upon my LastPass subscription.

While there are two certainties for everyone in life (death and taxes), for me there are also two Sisyphean tasks that I continue to work on:

  • Unsubscribing for emails
  • Ceasing subscriptions

Figuring I could save some money here, and the inertia to leave a password manager is possibly even higher than leaving your bank, I felt up to the challenge.

Why not LastPass?

This would have been a good question 5-10 years ago, but honestly I feel like the more relevant question now is Why LastPass?

Since their acquisition by LogMeIn in 2015, the only new feature I've seen is an increase in cost. I've been a premium subscriber for a few years in order to share password folders, but it still irks me that they use predatory practices to stymie usage of their free account.

The most egregious of which is the recent limitation of one device per free user. Honestly, which person nowadays only has one device? I find it to be an extremely security hindering limitation for a company that states:

Security is our highest priority at LastPass

But it's not just price gouging that's given me the ick. It's also the aggregation of security incidents in 2015, 2016, 2017, 2019, and 2021. I want my most secure data to remain secure and my trust, the most thing important (currently) in an online world has been shaken. I say currently because zero-trust is neat.

What are the alternatives?

LastPass isn't the boy without a date at prom. There're a handful of password managers which have gained prevalence over the last few years to choose from from 1Password to KeyPassX to Dashlane. Each has their own benefits, drawbacks, and pricing.

Ultimately though, I chose to go with Bitwarden and it was down to three reasons:

While I could have signed up for a free account on the Bitwarden website, I decided to go full neckbeard and host my own password manager. This was 50:50 living my open source tenets as well as just seeing whether I could.

How did I install Bitwarden?

In a word: Ansible.

In a few more words, I created a new 1GB/1CPU Digital Ocean droplet (referral link) using Ubuntu 18.04 LTS because I wanted to ensure complete separation between where my passwords are stored and other servers. I also enabled automatic backups because why not right?

Once the server was provisioned, I SSH'd in and ran the following commands to install Ansible and the packages I'd require.

apt-get update
apt-get upgrade
add-apt-repository --yes --update ppa:ansible/ansible
apt install ansible
ansible-galaxy install geerlingguy.swap
ansible-galaxy install ahuffman.resolv
ansible-galaxy install geerlingguy.security
ansible-galaxy install geerlingguy.firewall
ansible-galaxy install geerlingguy.ntp
ansible-galaxy install geerlingguy.certbot
ansible-galaxy install geerlingguy.nginx
ansible-galaxy install geerlingguy.postgresql
ansible-galaxy install geerlingguy.postfix
ansible-galaxy install jenstimmerman.vaultwarden
ansible-galaxy install adamruzicka.wireguard

I did end up having to use the version of jenstimmerman.vaultwarden from GitHub rather than Ansible Galaxy because 0.5 hadn't been pushed. The author has since fixed that though!

After this, I created a custom role and placed it the following configuration in  /etc/ansible/roles/yphonius.servername/tasks/main.yml for some of the tweaks I'd need in the server:

# Create required users and ensure periodic running of Ansible
- name: Ensure typhonius group exists
    name: typhonius
    state: present

- name: Add the user typhonius
    name: typhonius
    groups: typhonius,sudo
    create_home: true
    shell: '/bin/bash'

- name: Installing ssh key for typhonius
    user: typhonius
    key: "{{ lookup('file', './files/authorized_keys.typhonius.pub') }}"

- name: Add the user bitwarden
    name: bitwarden
    create_home: false
    shell: '/bin/nologin'

- name: Runs Ansible on cron
    name: "Ansible cron"
    state: "present"
    user: "root"
    hour: "15"
    minute: "0"
    job: '/usr/bin/ansible-playbook /etc/ansible/servername.yml'

# Required packages for Certbot
- name: install unzip
    name: unzip
    state: present

- name: install openresolv
    name: openresolv
    state: present

# For Wireguard
- sysctl:
    name: net.ipv4.ip_forward
    value: '1'
    state: present
    reload: yes

- sysctl:
    name: net.ipv6.conf.all.forwarding
    value: '1'
    state: present
    reload: yes

- sysctl:
    name: net.ipv4.conf.wg0.route_localnet
    value: '1'
    state: present
    reload: yes

I then created a servername.yml in /etc/ansible and filled it with the following

- hosts: localhost
    - vars/main.yml
    - { role: typhonius.servername }
    - { role: geerlingguy.swap }
    - { role: ahuffman.resolv }
    - { role: geerlingguy.security }
    - { role: geerlingguy.firewall }
    - { role: geerlingguy.ntp }
    - { role: geerlingguy.certbot }
    - { role: geerlingguy.nginx }
    - { role: geerlingguy.postgresql }
    - { role: geerlingguy.postfix }
    - { role: jenstimmerman.vaultwarden }
    - { role: adamruzicka.wireguard }

ansible_python_interpreter: /usr/bin/python3

# geerlingguy.nginx
nginx_extra_http_options: |
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;

nginx_remove_default_vhost: true
nginx_server_tokens: "off"
nginx_multi_accept: "on"
nginx_listen_ipv6: false
  - server_name: "servername.adammalone.net"
    listen: " ssl http2"
    state: "present"
    template: "{{ nginx_vhost_template }}"
    filename: "servername.adammalone.net-https.conf"
    extra_parameters: |
        location / { deny all; }
        ssl_certificate     /etc/letsencrypt/live/servername.adammalone.net/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/servername.adammalone.net/privkey.pem;
  - server_name: "bitwarden.adammalone.net"
    listen: " ssl http2"
    filename: "bitwarden.adammalone.net-https.conf"
    extra_parameters: |
        ssl_certificate     /etc/letsencrypt/live/bitwarden.adammalone.net/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/bitwarden.adammalone.net/privkey.pem;
        location / {
                proxy_pass http://localhost:8008/;
                proxy_set_header Host $server_name;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        location /notifications/hub {
                proxy_pass http://localhost:3003;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
        location /notifications/hub/negotiate {
                proxy_pass http://localhost:8008;
        add_header X-Frame-Options SAMEORIGIN;
        add_header Referrer-Policy "strict-origin-when-cross-origin";
        add_header Content-Security-Policy "default-src 'self'; prefetch-src 'self'; connect-src 'self' adammalone.report-uri.com; font-src 'self' data:; frame-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' ; style-src 'self' 'unsafe-inline'; media-src 'self'; base-uri 'self'; report-to csp-endpoint";
        add_header Report-To '{"group":"csp-endpoint","max_age":31536000,"endpoints":[{"url":"https://adammalone.report-uri.com/r/d/csp/enforce"}]},{"group":"default","max_age":31536000,"endpoints":[{"url":"https://adammalone.report-uri.com/a/d/g"}],"include_subdomains":true}';
        add_header X-Content-Type-Options "nosniff";
        add_header Feature-Policy "accelerometer 'none'; camera 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; payment 'none'; usb 'none'";

# geerlingguy.ntp
ntp_manage_config: true
  - ""
  - "::1"

# geerlingguy.security
security_ssh_port: 38387
security_autoupdate_mail_to: "[email protected]"
  - typhonius

# geerlingguy.firewall
  - "38387" # SSH
  - "80" # Certbot
  - "53" # Wireguard
  - "55290" # Wireguard

  - "iptables -t nat -A PREROUTING -p tcp -i wg0 --dport 443  -d REDACTED -j DNAT --to-destination"
  - "iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-ports 51820 -i eth0"
  - "iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE"
  - "iptables -A INPUT -p tcp -i wg0 --dport 443 -j ACCEPT"

# geerlingguy.postgres
  - name: bitwarden
    password: REDACTED

  - name: bitwarden
    owner: bitwarden

# jenstimmerman.vaultwarden
vaultwarden_version: 1.23.1
vaultwarden_webvault_version: 2.25.0
  DOMAIN: "https://bitwarden.adammalone.net"
  DOMAIN_PATH: ""  # results in a domain of https://example.com/vaultwarden/, needs to start with a '/'
  DATABASE_URL: "postgresql://bitwarden:[email protected]/bitwarden?host=/run/postgresql/"
  SMTP_FROM: '[email protected]'
  SMTP_FROM_NAME: 'bitwarden'
  SMTP_HOST: smtp.sendgrid.net
  SMTP_PORT: 587
  SMTP_SSL: true

# adamruzicka.wireguard
  - wg0

  private_key: REDACTED
  listen_port: 51820
  post_up: 'iptables -A FORWARD -i %i -j wireguard; iptables -A FORWARD -o %i -j wireguard; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE'
  post_down: 'iptables -D FORWARD -i %i -j wireguard; iptables -D FORWARD -o %i -j wireguard; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE'

    public_key: REDACTED
    public_key: REDACTED

# ahuffman.ansible-resolv
  - ""
  - ""
  - "timeout:2"
  - "rotate"

# geerlingguy.certbot
certbot_create_if_missing: true
certbot_admin_email: [email protected]
 - domains:
     - servername.adammalone.net
 - domains:
     - bitwarden.adammalone.net

Is it more secure?

As a result of the firewalling, the server is for all intents and purposes as locked down as is possible with a single box. Yes, I could have added in jump servers/bastion hosts and further increased complexity but the following was Good Enough™ for my needs.

The only publicly available TCP port is my SSH port which greatly limits the attack surface. In order for me to get access to any of the passwords within Bitwarden, I need to authenticate via WireGuard which will then give me access to NGINX.

Without authenticating, anyone trying to access the server won't be able to access anything and if they navigate to the Bitwarden URL then the page simply won't load and instead will error out.

I decided to architect the configuration in this way to provide me with a little extra protection in the event of a Bitwarden vulnerability. If a bad actor isn't able to access the Bitwarden instance, then they won't be able to attack it. This is my way of attempting to use defence in depth.

Am I going to keep it?

Honestly, probably not. But maybe.

As a proof of concept super fun, and I've been using it successfully on all my devices for over six months. That being said, I'm probably going to switch over to one of the Bitwarden hosted plans reasonably shortly for two reasons:

  • I don't really have a backup strategy for Bitwarden aside from block level server backups and that's scary. Some exist, but I would want to write my own as another fun project (which I can then of course open source). The main issue here is lack of pgSQL support in existing repos
  • Having to activate Wireguard every time I want to save a new password is actually a massive pain – even if it's only one click