Hosting Vaultwarden on fly.io for free

Published 22.04.2023 • Last modified 13.05.2024

After Lastpass suffered a massive data breach last year, I began looking for an alternative password manager. Bitwarden is another centralized password manager like LastPass, but with the added benefit of being completely open source! They also allow you to spin up your own Bitwarden server and connect to them with all of their clients.

Fly.io has a generous free tier, allowing up to 3 virtual machines with 256 MB of RAM, and 3 GB of persistent volume. 256 MB of ram isn’t a lot, but it should be enough, right? As it turns out, Bitwarden’s official server requires a minimum of 2 GIGABYTES of RAM. That is way more than we can afford. This is where Vaultwarden comes in. It’s an unofficial server for Bitwarden, which is written in Rust, runs in a single process with minimal RAM usage, and uses SQLite as the database. They even have a simple Docker image, which we will be using to spin up the server on fly.io.

Getting the config #

The first step is to make an account on fly.io, and then install the CLI. Once you have the CLI installed, you can create a new app:

$ fly launch
? Choose an app name (leave blank to generate one): vault-aarol
? Choose a region for deployment: Stockholm, Sweden (arn)
Created app 'vault-aarol' in organization 'personal'
Admin URL: https://fly.io/apps/vault-aarol
Hostname: vault-aarol.fly.dev
Wrote config file fly.toml

Choose an appropriate name for the app, and the location closest to you. fly launch will generate a fly.toml, which should look something like this:

app = "vault-aarol"
kill_signal = "SIGINT"
kill_timeout = 5
primary_region = "arn"
processes = []

[env]

[experimental]
  auto_rollback = true

[[services]]
  http_checks = []
  internal_port = 8080
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

  [[services.ports]]
    force_https = true
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

The generated file consists mostly of networking stuff. We don’t need to worry about enabling HTTPS, because fly.io already manages that for us. We can instead focus on the actual application, which is just a couple lines more.

Copy these lines to the config, replacing the empty “[env]” block with the following:

[env]
  SIGNUPS_ALLOWED = "true"

[build]
  image = "vaultwarden/server:latest"

[mounts]
  source = "vw_data"
  destination = "/data"

There are a bunch of environment variables to configure Vaultwarden, but I’ve singled out the most important one. Since our server will be open to the whole internet, security is going to be very important. Disabling signups means that the potential attack surface is reduced to a single login page. You should set “SIGNUPS_ALLOWED” to false after creating your account(s), just to be sure.

We also need to change internal_port to 80, since that is what the Vaultwarden Docker image uses.

[[services]]
  http_checks = []
  internal_port = 80

Creating the volume #

If we would try to deploy now, we would get an error, because the mount point called “wv_data” doesn’t exist yet. We can create it with the following:

$ fly volumes create vw_data --size 1

Here we create a volume vw_data with a size of 1GB (vs. the default 3GB), since it’s more than enough for a password manager, and also because we’re nice.

Running the command will probably give you a huge red warning like this:

Warning! Individual volumes are pinned to individual hosts. You should create two or more volumes per application. You will have downtime if you only create one. Learn more at https://fly.io/docs/reference/volumes/

It’s complaining because we’re only creating a single volume for our app. Fly.io volumes are NVME drives on the physical server that your app runs on. If the NVME drive fails, it will bring the whole app down, and might even cause permanent data loss :(

The docs also say the following:

If we wanted to use two volumes, we would need two separate apps using two different volumes, and would need to somehow keep them in sync. That sounds way too complicated. I’d rather keep it simple and use one volume/app, even at the cost of some reliability.

Encrypted copies of personal vaults are kept on logged-in devices for offline use. Even in the worst possible scenario, where the whole server is gone, one could still export the vault as JSON from a device that has a copy of the vault.

I’ve been using a single volume for about three months, and so far my app has stayed up without any downtime. Three months isn’t a long time though – I’ll update this page if something happens.

Another alternative to volumes is Fly Postgres, which can be clustered, and thus doesn’t rely on a single NVME drive. Vaultwarden has support for Postgres as well. I might look into it in the future.

Anyway, enter ‘Y’ to skip the warning. Now we have a volume and a fly.toml. We can finally deploy:

$ fly deploy
==> Verifying app config
Validating ~/vault/fly.toml
Platform: machines
✓ Configuration is valid
--> Verified app config
==> Building image
Searching for image 'vaultwarden/server:latest' remotely...
image found: img_3mno4wmd3kmvk18q
Deploying vault-aarol app with rolling strategy
  Machine e784ee72cdd783 [app] update finished: success       
  Finished deploying

If it deploys successfully, the provided URL should show..

VaultWarden login page

Boom! Now we have a fully working Vaultwarden instance! Whenever you want to upgrade the Vaultwarden version, simply run fly deploy again.

Using your own domain #

The provided domain (https://[whatever]-vault.fly.dev) is pretty nice, but even nicer is to use your own domain. Lets say your domain is example.com. Wouldn’t it be nice to access the vault from vault.example.com?

First, you need to set a CNAME (alias) record that points to the fly domain. There are a billion DNS management systems, but it should look something like this: Editing CNAME record with name set to “vault” and value set to “whatever-vault.fly.dev.” This sets a CNAME record for a subdomain, so that the URL will be vault.example.com.

Notice the dot after fly.dev. If you don’t have the trailing dot, it’s going to alias it to [whatever]-vault.fly.dev.example.com , which is just wrong.

Next, you need to add a certificate for that subdomain on fly.io’s side:

$ fly certs add vault.example.com

Now, visiting vault.example.com should show the same login page. All the actual traffic is going to the fly.dev domain, but the address in the URL bar looks nice and clean!

Backing up the data #

As you know from before, Fly.io takes daily snapshots of your volumes, and keeps those snapshots for 5 days. They also recommend another primary backup method. Let’s make one!

The vaultwarden Docker image stores the data in the /data directory. Let’s SSH into the app:

$ fly ssh console
Connecting to 7b36:2bcb:e402:4de9:dbb2:2... complete
# ls
bin   dev             home   media  proc  sbin      sys  var
boot  etc             lib    mnt    root  srv       tmp  vaultwarden
data  healthcheck.sh  lib64  opt    run   start.sh  usr  web-vault
# ls /data
attachments  db.sqlite3-shm  icon_cache  rsa_key.pem      sends
db.sqlite3   db.sqlite3-wal  lost+found  rsa_key.pub.pem  tmp

As you can see, the data directory contains a SQLite database and other important files. db.sqlite3 contains all of the user data, and is fully encrypted. To automate the backup process, I’ve written the PowerShell script below.

Create a new file backup.ps1 in the same directory as the fly.toml file:

$DATE = get-date -Format "yyyy-MM-dd"

$INSTALL_SQLITE = "apt-get update && apt-get install sqlite3 -y"
$BACKUP_DB = "sqlite3 /data/db.sqlite3 '.backup /data/db.bak'"
$CREATE_ARCHIVE = "tar -czf $DATE.tar.gz data" 

fly ssh console -q -C "bash -c ""$INSTALL_SQLITE && $BACKUP_DB && $CREATE_ARCHIVE"" "

fly sftp get "$DATE.tar.gz"

Basically, it will SSH into the app, create a backup of the database using the sqlite3 CLI, and then create a gzipped tar archive of the entire data directory. Finally, it transfers the archive to the current working directory using SFTP.

The script ended up being more work than anticipated, but I’ve tried to make it a bit more readable. You can run the script using Powershell: powershell ./backup.ps1. A file that looks like 2023-04-21.tar.gz should now appear in the directory.

The file is very small, about 2 MB for my vault. Even that size is inflated because of the icon_cache directory, which contains icons for every website in the vault.

The naive way to backup a sqlite database is just to cp it. However, if the database happens to be modified during the backup, it may lead to corruption. sqlite3’s .backup command will prevent this by locking the database for the duration of the copy, which should only be a fraction of a second. Restoring the database should be as simple as running rm db.sqlite3 && mv db.bak db.sqlite3

If you don’t want to do run the command manually, you can register a schedule to execute it automatically.

Websocket support #

As of Vaultwarden version 1.29, Websocket support is enabled by default 🎉. This means that any changes you make to the vault should be instantly visible on all devices. Previously, this required a reverse proxy (NGINX, Caddy) because the Websocket server was running on a different port.

Download sample fly.toml