Hosting Vaultwarden on fly.io for free
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:
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:
- You can’t share a volume between apps, nor can two VMs mount the same volume at the same time. A single VM can only mount one volume at a time.
- If you need the volumes to sync up, your app has to make that happen.
- If you only have a single copy of your data on a single Fly Volume, and that drive fails, data is lost. Fly.io takes daily snapshots, retained for 5 days, but these are meant as a backstop, not your primary backup method.
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..
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:
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.