Bitwarden is a password manager that allows users to generate and store strong passwords. It also handles other types of data like secure notes and credit card information. At the time of this writing, it is one of the best password managers out there because in addition to offering strong and zero-knowledge encryption, the codebase is open source. This means that anyone can inspect the code and run it for their personal use. In this article, we’ll go over the steps to build a fully functioning Bitwarden instance that anyone can run on a server at home.

Docker setup

Let’s start by setting up a docker-compose.yml file to run the Bitwarden server:

version: "3.8"

services:
  bitwarden:
    container_name: "bitwarden"
    image: vaultwarden/server
    ports:
      - "3012:3012"
    volumes:
      - data:/data

volumes:
  data:

Here, we’re using vaultwarden/server image which is a rust-based version of Bitwarden server. We’re exposing a TCP port to be able to communicate with the container from outside of Docker network. Then, we create a volume and mount it on /data inside the container. That’s where Bitwarden stores its data, including users, vaults, and attachments.

If we bring up this container and try to access Bitwarden through https://localhost:3012, it will not work. Bitwarden requires all communications to go through a TLS tunnel. In other words, we need to use HTTPS instead of HTTP. We’re going to use a second container running Nginx to handle TLS for us and proxy requests to Bitwarden’s container.

version: "3.8"

services:
  bitwarden:
    # ...
    depends_on:
      - nginx

  nginx:
    image: nginx:1.21.0-alpine
    ports:
      - "23984:443"
    volumes:
      - ./nginx/ssl:/etc/ssl:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    restart: unless-stopped

volumes:
  data:

In this configuration, we’re declaring a new service nginx using the alpine image of nginx, exposing a port so that the container can be reacher from outside Docker’s internal network, and specifying a couple of read-only volumes. We’re also instructing Docker to automatically restart this container unless it was manually stopped. This can help increase availability by automatically recovering from sudden crashes.

The final result looks like follows. Notice that we introduced a environment variable ADMIN_TOKEN that can be used to access the admin section of Bitwarden. More on how to do this later.

version: "3.8"

services:
  bitwarden:
    container_name: "bitwarden"
    image: vaultwarden/server
    environment:
      - ADMIN_TOKEN=SOME_SECRET_TOKEN
    volumes:
      - data:/data
    depends_on:
      - nginx

  nginx:
    image: nginx:1.21.0-alpine
    ports:
      - "23984:443"
    volumes:
      - ./nginx/ssl:/etc/ssl:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    restart: unless-stopped

volumes:
  data:

TLS Support

Now that we have our containers set up, it’s time to configure Nginx to handle TLS connections and forward them to Bitwarden’s container. Let’s create a server configuration file named bitwarden.conf and place it under nginx/conf.d/. This path relative to wherever you place the docker-compose.yml file.

server {
    listen 443 ssl http2;

    # Allow large attachments
    client_max_body_size 128M;

    location / {
        proxy_pass http://bitwarden:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /notifications/hub {
        proxy_pass http://bitwarden:3012;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /notifications/hub/negotiate {
        proxy_pass http://bitwarden:80;
    }
}

This is a pretty typical Nginx proxy configuration where we define a port to listen to along with protocols we want to handle. Next, we have a directive to allow large attachments1 and we declare few paths and how they should be handled. We’re not going to dive into each directive used here but If you want to know more about how to configure Nginx, I encourage to check out their official documentation.

If you’ve been following along you’ll notice that we haven’t addressed the SSL connection yet, and without it our setup will not work. So let’s figure that out. In case you’re planning to use a real domain name for your Bitwarden instance, you can skip this section and jump directly to Nginx Configuration.

In order to make a self-signed TLS certificate, first we need to generate a private key and a certificate for our Bitwarden server using the following command.

OUT_FOLDER=/path/to/ssl
DOMAIN=your.bitwarden.domain

openssl req -x509 -nodes -days 365 -newkey rsa:4096 \
        -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=DNS:$DOMAIN\nbasicConstraints=CA:true")) \
        -keyout $OUT_FOLDER/private/nginx-bitwarden.key \
        -out $OUT_FOLDER/certs/nginx-bitwarden.cert \
        -reqexts SAN -extensions SAN \
        -subj "/C=US/ST=New York/L=New York/O=Company Name/OU=Bitwarden/CN=$DOMAIN"

This will generate a new public key nginx-bitwarden.key using RSA with a key length of 4096 bits, and a self-signed TLS certificate nginx-bitwarden.cert valid for 365 days in the private/ and certs/ folders respectively. These folders should be placed under the folder ssl/ that lives alongside our docker-compose.yml file, so make sure you set the $OUT_FOLDER variable properly. In addition, the $DOMAIN should be set to the fully qualified domain name of the machine where you’re planning to run Bitwarden and Nginx. It’s not complicated to assign an FQDN to your local machine using a local DNS, especially if you’re running Pihole. If you’re interested in getting Pihole set up locally with Docker, check out this article.

Nginx Configuration

Now that we have our certificate and private key, we should tell Nginx where they are located in order to use them for TLS connections.

server {
    listen 443 ssl http2;

    ssl_certificate      /etc/ssl/certs/nginx-bitwarden.crt;
    ssl_certificate_key  /etc/ssl/private/nginx-bitwarden.key;

    # ...
}

It’s recommended to generate a Diffie-Hellman file for stronger connections and use it in our Nginx configuration. We can generate one with the command:

openssl dhparam -out $OUT_FOLDER/certs/dhparam.pem 4096

To be honest, I haven’t yet looked at the functions of this Diffie-Hellman file to fully understand how it improves security. But if you know more about it and are interested in contributing your knowledge to this article, feel free to reach out to me on Mastodon.

The complete Nginx configuration after linking the .pem file should look like follows.

server {
    listen 443 ssl http2;

    ssl_certificate      /etc/ssl/certs/nginx-bitwarden.crt;
    ssl_certificate_key  /etc/ssl/private/nginx-bitwarden.key;

    ssl_dhparam /etc/ssl/certs/dhparam.pem;

    client_max_body_size 128M;

    location / {
        proxy_pass http://bitwarden:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /notifications/hub {
        proxy_pass http://bitwarden:3012;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /notifications/hub/negotiate {
        proxy_pass http://bitwarden:80;
    }
}

Finally, we have our containers configured to run Bitwarden. All we need do to in order to bring them up is run:

docker-compose up -d bitwarden

Once the containers are up, you’ll be able to reach Bitwarden through its domain name. Given our example configuration, that would be https://your.bitwarden.domain. The first time you open the page, your browser will most likely complain about an insecure connection and display a flashy warning. If it doesn’t, stop using this browser and get yourself a real one! This is due to the self-signed certificate. Since the certificate is not signed by a Certificate Authority (CA) present in the browsers or the OS’s root CA store, the browser considers the connection insecure. You can add an exception for this certificate in your browser so that it doesn’t freak out every time you access your self-hosted Bitwarden. Similarly, you will have to install this certificate on your device and trust it, otherwise the OS will prevent Bitwarden’s app to communicate with our running container. To do so, send the nginx-bitwarden.crt file we generated in the TLS Support section to your device2, open it and follow the installation steps. It should be straightforward in most OS’s but it’s always safe to follow the official guide to make sure you cover all the steps. For instance, in iOS, on top of installing the certificate you have to explicitly trust it for TLS connections.

Admin Access

Like we’ve seen in a previous section, in order to unlock the admin panel we need to set a token in the ADMIN_TOKEN environment variable. Let’s generate a token using the following command.

openssl rand -base64 48

Once Bitwarden’s container is restarted after setting the admin token, you can access the admin panel at https://your.bitwarden.domain/admin.

Wrap up

We finally made it. We built a docker setup running a container for the Bitwarden server with an Nginx proxy handling TLS connections using a self-signed certificate. This grants us full control over our credentials and any other data we choose to store in Bitwarden, all without having to trust any third party to handle this for us.


  1. This one is optional and is only necessary if you use Bitwarden for attachments or to send large files. ↩︎

  2. It’s safe to use email here since the certificate doesn’t contain any sensitive information. ↩︎