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
  group:
    name: typhonius
    state: present

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

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

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

- name: Runs Ansible on cron
  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
  package:
    name: unzip
    state: present

- name: install openresolv
  package:
    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_files:
    - vars/main.yml
  roles:
    - { 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: |
        resolver 1.1.1.1;
        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
nginx_vhosts:
  - server_name: "servername.adammalone.net"
    listen: "127.0.0.1:443 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: "127.0.0.1:443 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
  - "127.0.0.1"
  - "::1"

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

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

firewall_additional_rules:
  - "iptables -t nat -A PREROUTING -p tcp -i wg0 --dport 443  -d REDACTED -j DNAT --to-destination 127.0.0.1"
  - "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
postgresql_users:
  - name: bitwarden
    password: REDACTED

postgresql_databases:
  - name: bitwarden
    owner: bitwarden

# jenstimmerman.vaultwarden
vaultwarden_version: 1.23.1
vaultwarden_webvault_version: 2.25.0
vaultwarden_config:
  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:REDACTED@/bitwarden?host=/run/postgresql/"
  ROCKET_ADDRESS: 127.0.0.1
  ROCKET_PORT: 8008
  SIGNUPS_ALLOWED: false
  SIGNUPS_VERIFY: true
  SIGNUPS_DOMAINS_WHITELIST: 'adammalone.net'
  INVITATIONS_ALLOWED: 'false'
  SMTP_FROM: '[email protected]'
  SMTP_FROM_NAME: 'bitwarden'
  SMTP_HOST: smtp.sendgrid.net
  SMTP_PORT: 587
  SMTP_SSL: true
  SMTP_EXPLICIT_TLS: false
  SMTP_USERNAME: apikey
  SMTP_PASSWORD: REDACTED
  SMTP_AUTH_MECHANISM: "Login"
  WEBSOCKET_ENABLED: true
  WEBSOCKET_ADDRESS: 127.0.0.1
  WEBSOCKET_PORT: 3003
  #ADMIN_TOKEN: "REDACTED"

# adamruzicka.wireguard
wireguard_networks:
  - wg0

wireguard_wg0_interface:
  address: 10.10.0.0/16
  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'
  dns: 1.1.1.1

wireguard_wg0_peers:
  laptop:
    public_key: REDACTED
    allowed_ips: 10.10.10.96/32
  mobile:
    public_key: REDACTED
    allowed_ips: 10.10.10.97/32

# ahuffman.ansible-resolv
resolv_nameservers:
  - "1.1.1.1"
  - "1.0.0.1"
resolv_options:
  - "timeout:2"
  - "rotate"

# geerlingguy.certbot
certbot_create_if_missing: true
certbot_admin_email: [email protected]
certbot_certs:
 - 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