Aller au contenu
ActiveCommencez gratuitement · Crédit de 100 $ CA à l'inscription, jusqu'à 300 $ CAPaiement de vérification de 1 $ CA crédité · Fin le 31 décembre 2026
Commencer gratuitement →

Back Up Vaultwarden to Object Storage with restic

Ce contenu n’est pas encore disponible dans votre langue.

restic is a backup program that encrypts and signs every byte on the machine being backed up, before anything reaches the storage backend. Its Amazon S3 walkthrough is a well-known reference, and because ZCP object storage speaks the S3 API, the same setup works here with one changed endpoint.

This tutorial builds the whole thing end to end. Terraform or OpenTofu provisions a Vaultwarden password-manager VM, a network, a public IP, and an object storage bucket. A bash script then backs the SQLite database up to that bucket on a nightly timer.

By the end you have:

  • Vaultwarden running on a VM you can reach over HTTPS
  • An object storage bucket holding a restic repository
  • A backup script on a systemd timer, with retention and integrity checks
  • Proof that what lands in the bucket is ciphertext, and a restore that turns it back into a working vault

Plan for about 45 minutes.

  • A ZSoftly Public Cloud account. Sign up if you do not have one.
  • Terraform 1.0 or later, or OpenTofu 1.6 or later.
  • The zcp CLI, to look up slugs and inspect the bucket.
  • An SSH key pair. Generate one with ssh-keygen -t ed25519 if you need to.

In the console, go to Account → API Keys, create a token, and export it. The provider and the CLI both read the same variable:

export ZCP_BEARER_TOKEN="<your-token>"

The token stays out of your configuration and out of your state file.

Compute and object storage live in separate regions. Virtual machines run in yul-1 (Montreal) or yow-1 (Ottawa), and object storage stores sit in os-yul or os-yow. Pick the pair in the same city for the lowest latency and the smallest transfer bill, or the other city to keep the backup outside the failure domain of the VM it protects.

zcp region list # regions, including the os- ones
zcp project list # your project slug
zcp plan vm --region yul-1 # compute plans
zcp plan network --region yul-1 # network plans
zcp plan ip --region yul-1 # public IP plans
zcp storage-category list --region yul-1 # VM disk categories
zcp template list --region yul-1 | grep -i vaultwarden
zcp plan object-storage --region os-yul # object storage sizes
zcp storage-category list --region os-yul # object storage categories

The Vaultwarden image is a Marketplace app. It ships Vaultwarden in Docker Compose on Ubuntu 24.04, generates a self-signed certificate and an admin token on first boot, and serves the web vault on port 8000.

Create an empty directory with one file, main.tf. Object storage takes its own region data source, so nothing is hardcoded twice:

terraform {
required_providers {
zcp = {
source = "zsoftly/zcp"
version = "~> 0.2"
}
}
}
provider "zcp" {
default_project = "default-9"
}
variable "admin_cidr" {
description = "CIDR allowed to reach SSH and the web vault. Deliberately has no default."
type = string
}
data "zcp_region" "compute" {
slug = "yul-1"
}
data "zcp_region" "objects" {
slug = "os-yul"
}
resource "zcp_ssh_key" "vault" {
name = "vault-key"
region = data.zcp_region.compute.slug
public_key = file("~/.ssh/id_ed25519.pub")
}
resource "zcp_network" "vault" {
name = "vault-network"
cloud_provider = data.zcp_region.compute.cloud_provider
region = data.zcp_region.compute.slug
network_plan = "pnet-yul"
billing_cycle = "hourly"
}
resource "zcp_instance" "vault" {
name = "vault-01"
cloud_provider = data.zcp_region.compute.cloud_provider
region = data.zcp_region.compute.slug
template = "zmi-vaultwarden-1360-ubuntu2404-100-1"
plan = "ca2sm"
billing_cycle = "hourly"
network = zcp_network.vault.id
storage_category = "premium-ssd"
ssh_key = zcp_ssh_key.vault.name
assign_public_ip = false
}
resource "zcp_ip_address" "vault" {
plan = "ipv4-yul"
billing_cycle = "hourly"
network = zcp_network.vault.id
}
resource "zcp_egress_rule" "https_out" {
network = zcp_network.vault.id
protocol = "tcp"
cidr = "0.0.0.0/0"
start_port = "443"
end_port = "443"
}
resource "zcp_egress_rule" "http_out" {
network = zcp_network.vault.id
protocol = "tcp"
cidr = "0.0.0.0/0"
start_port = "80"
end_port = "80"
}
resource "zcp_egress_rule" "dns_out" {
network = zcp_network.vault.id
protocol = "udp"
cidr = "0.0.0.0/0"
start_port = "53"
end_port = "53"
}
resource "zcp_firewall_rule" "ssh" {
ip_address = zcp_ip_address.vault.id
protocol = "tcp"
cidr_list = var.admin_cidr
start_port = "22"
end_port = "22"
}
resource "zcp_firewall_rule" "vault_web" {
ip_address = zcp_ip_address.vault.id
protocol = "tcp"
cidr_list = var.admin_cidr
start_port = "8000"
end_port = "8000"
}
resource "zcp_port_forward" "ssh" {
ip_address = zcp_ip_address.vault.id
protocol = "tcp"
public_start_port = "22"
public_end_port = "22"
private_start_port = "22"
private_end_port = "22"
virtual_machine = zcp_instance.vault.id
}
resource "zcp_port_forward" "vault_web" {
ip_address = zcp_ip_address.vault.id
protocol = "tcp"
public_start_port = "8000"
public_end_port = "8000"
private_start_port = "8000"
private_end_port = "8000"
virtual_machine = zcp_instance.vault.id
}
resource "zcp_object_storage" "backups" {
name = "vault-backups"
cloud_provider = data.zcp_region.objects.cloud_provider
region = data.zcp_region.objects.slug
billing_cycle = "hourly"
storage_category = "ssd-storage"
plan = "o210g"
}
resource "zcp_object_storage_bucket" "restic" {
object_storage = zcp_object_storage.backups.id
name = "vaultwarden-restic"
}
output "vault_ip" {
value = zcp_ip_address.vault.ip_address
}
output "object_storage_slug" {
value = zcp_object_storage.backups.id
}
# The platform assigns the bucket a suffixed identifier when it is created, and that
# value is both the bucket slug and the S3 bucket name. The `name` argument above is
# only the name requested, so never use it in an endpoint URL.
output "bucket_name" {
value = zcp_object_storage_bucket.restic.id
}
output "s3_access_key" {
value = zcp_object_storage.backups.api_key
sensitive = true
}
output "s3_secret_key" {
value = zcp_object_storage.backups.api_secret
sensitive = true
}

Several choices in this file are not obvious. Get one wrong and you end up with a VM you cannot reach, or one you cannot back up.

The egress rules are not optional here. A network you declare as its own resource starts with no egress rules and denies all outbound traffic. Without those three rules the VM cannot reach the package archive, GitHub, or the object storage endpoint, and every command in the later steps times out. The rules above allow HTTPS, HTTP, and DNS traffic to any destination. Narrow the destination CIDRs once you know which hosts you need.

This is specific to managing the network yourself. A network auto-created by the VM, through network_plan or the CLI’s --network-plan, is provisioned with working outbound access. Taking ownership of the network for a clean destroy means taking ownership of its egress rules too.

Ingress goes through port forwards, not static NAT. The first public IP allocated into a fresh network becomes that network’s source-NAT address, and the platform refuses to put static NAT on it. A zcp_ip_association against that IP reports success and changes nothing, so traffic never arrives. Use zcp_port_forward instead. Each forward needs a firewall rule on the same IP to let the traffic in.

assign_public_ip = false on the instance keeps the platform from allocating a second address. The zcp_ip_address resource supplies the one address the network uses.

zcp_ssh_key takes a region argument. The registry documentation for this resource omits it. The API needs it to derive the cloud provider, so leaving it out fails validation.

Give the restic repository its own bucket. A repository takes exclusive ownership of the paths it writes, so do not point a second workload at the same bucket.

tofu init
tofu plan
tofu apply

The apply blocks until the VM reports Running. The VM is the slow part, at about two and a half minutes, and the whole stack lands in three to four minutes. Read the outputs:

tofu output vault_ip
tofu output object_storage_slug
tofu output bucket_name
tofu output -raw s3_access_key
tofu output -raw s3_secret_key

object_storage_slug is the store’s slug, which is what every zcp object-storage command wants as its first argument. bucket_name comes from the bucket resource’s id, which the platform sets to the suffixed value it assigned. The suffixed value is the string the S3 endpoint answers to. Confirm it against the platform before you rely on it, since the resource’s name argument still holds the unsuffixed name you typed:

zcp object-storage bucket list <object_storage_slug> --region os-yul --project default-9

Terraform marks the two key outputs sensitive, so they need -raw to print. It still writes them to state in cleartext. Any provider returning a credential behaves the same way. Keep the state file somewhere you would be willing to keep the credential itself.

Step 5: Open the vault and put something in it

Section titled “Step 5: Open the vault and put something in it”

First boot takes under a minute. Connect and watch it finish:

ssh ubuntu@<vault_ip>
journalctl -u vaultwarden-first-boot.service -f

Read the generated admin token:

sudo cat /root/.credentials/vaultwarden.txt

Open https://<vault_ip>:8000 in a browser. The self-signed certificate triggers a warning, so accept the exception. Self-registration is off by default, so invite yourself from the admin panel at https://<vault_ip>:8000/admin, under Users, then log in and save one password item. You will destroy and restore that item later, so it needs to exist before the first backup.

The vault data lives in /data/vaultwarden/data: db.sqlite3, the attachments and sends directories, config.json, and the RSA key pair that signs client tokens. The Compose file and the environment file sit one level up in /data/vaultwarden.

Ubuntu packages restic, but the archive lags upstream by several releases. Install the current binary and check it against the published checksums:

sudo apt-get update
sudo apt-get install -y bzip2 rsync sqlite3
RESTIC_VERSION=0.19.1
cd /tmp
curl -fsSLO "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"
curl -fsSLO "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS
bunzip2 "restic_${RESTIC_VERSION}_linux_amd64.bz2"
sudo install -m 755 "restic_${RESTIC_VERSION}_linux_amd64" /usr/local/bin/restic
restic version

sha256sum -c must print OK for the archive. If it does not, stop and download again.

restic reads its configuration from the environment. Put it in a root-only file rather than a shell profile, so the systemd timer and your interactive shell share one source of truth.

Generate a repository password first. Anyone who obtains a copy of the bucket still needs it to read your vault:

sudo install -d -m 700 /etc/restic
openssl rand -base64 32 | sudo tee /etc/restic/repo-password > /dev/null
sudo chmod 600 /etc/restic/repo-password
sudo cat /etc/restic/repo-password

Now write the environment file, substituting the endpoint for your region, the bucket name, and the two keys from the Terraform outputs:

sudo install -d -m 700 /var/cache/restic
sudo tee /etc/restic/vaultwarden.env > /dev/null <<'EOF'
RESTIC_REPOSITORY=s3:https://objects.yul.zcp.zsoftly.ca/<your-bucket-name>
RESTIC_PASSWORD_FILE=/etc/restic/repo-password
RESTIC_CACHE_DIR=/var/cache/restic
AWS_ACCESS_KEY_ID=<access-key>
AWS_SECRET_ACCESS_KEY=<secret-key>
EOF
sudo chmod 600 /etc/restic/vaultwarden.env

RESTIC_CACHE_DIR looks minor, but skipping it breaks the timer. systemd runs services without $HOME, and restic refuses to start when it cannot locate a cache directory. Without this variable set, the nightly timer in Step 9 fails with unable to locate cache directory: neither $XDG_CACHE_HOME nor $HOME are defined. Putting it in the shared env file fixes the timer and your interactive shell at once.

The endpoints are https://objects.yul.zcp.zsoftly.ca for os-yul and https://objects.yow.zcp.zsoftly.ca for os-yow. The path after the host is the bucket name, which is the s3:https://server/bucket form restic uses for any S3-compatible server.

The file is root-only, so load it in a root shell rather than your own. Every command run on the VM from here through Step 11 uses this root shell, so none of them carry sudo. The zcp commands in Step 10 are the exception and run on your workstation:

sudo -i
set -a; . /etc/restic/vaultwarden.env; set +a
restic init
created restic repository db62f25025 at s3:https://objects.yul.zcp.zsoftly.ca/vaultwarden-restic-001024
Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.

Vaultwarden keeps its data in SQLite, and copying a live SQLite file with cp can capture a half-written transaction. The .backup command in the sqlite3 client takes a consistent snapshot while the container keeps serving, so no downtime is needed.

Still in the root shell, write the script to /root/vaultwarden-backup.sh:

#!/usr/bin/env bash
# Back up Vaultwarden to ZCP object storage with restic.
set -euo pipefail
VW_DIR=/data/vaultwarden
DATA_DIR="${VW_DIR}/data"
STAGE_ROOT=/var/backups/vaultwarden
STAGE_DIR="${STAGE_ROOT}/stage"
ENV_FILE=/etc/restic/vaultwarden.env
set -a
# shellcheck disable=SC1090
. "${ENV_FILE}"
set +a
install -d -m 700 "${STAGE_ROOT}"
install -d -m 700 "${STAGE_DIR}"
trap 'rm -rf "${STAGE_DIR:?}"' EXIT
# Copy the file-based data, skipping the live database and anything regenerable.
rsync -a --delete \
--exclude 'db.sqlite3*' \
--exclude 'icon_cache/' \
--exclude 'tmp/' \
--exclude 'ssl/' \
"${DATA_DIR}/" "${STAGE_DIR}/data/"
# Snapshot the running database without stopping the container.
sqlite3 "${DATA_DIR}/db.sqlite3" ".backup '${STAGE_DIR}/data/db.sqlite3'"
# Refuse to upload a copy that is already damaged.
integrity=$(sqlite3 "${STAGE_DIR}/data/db.sqlite3" 'PRAGMA integrity_check;')
if [ "${integrity}" != "ok" ]; then
echo "[ERROR] integrity check on the staged database returned: ${integrity}" >&2
exit 1
fi
# The env file carries the admin token hash, so a restore needs it too.
install -m 600 "${VW_DIR}/vaultwarden.env" "${STAGE_DIR}/vaultwarden.env"
restic backup --tag vaultwarden "${STAGE_DIR}"
restic forget --tag vaultwarden \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 \
--prune
restic check
echo "[OK] vaultwarden backup finished"

Install it and run it once by hand:

install -m 700 /root/vaultwarden-backup.sh /usr/local/sbin/vaultwarden-backup.sh
/usr/local/sbin/vaultwarden-backup.sh

The first run uploads everything. Later runs send only changed chunks, so a vault that gains a few passwords a day costs kilobytes per night. A fresh vault produces a very small first snapshot:

Files: 3 new, 0 changed, 0 unmodified
Dirs: 5 new, 0 changed, 0 unmodified
Added to the repository: 276.673 KiB (9.721 KiB stored)
processed 3 files, 274.046 KiB in 0:00
snapshot f5eb9b2f saved

Check what landed:

restic snapshots
ID Time Host Tags Paths Size
f5eb9b2f 2026-09-13 21:39:33 vwtest-01 vaultwarden /var/backups/vaultwarden/stage 274.046 KiB

The retention line keeps 7 daily, 4 weekly, and 6 monthly snapshots and prunes whatever falls outside all three. restic check verifies that every pack and index the snapshots reference is present. On a small repository it takes seconds. If the repository grows past a few tens of gigabytes, move the check to its own weekly timer and add --read-data-subset=1/7 there, which reads and verifies one seventh of the actual pack data per run.

Create the service unit at /etc/systemd/system/vaultwarden-backup.service:

[Unit]
Description=Back up Vaultwarden to ZCP object storage with restic
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vaultwarden-backup.sh
Nice=10
IOSchedulingClass=idle

And the timer at /etc/systemd/system/vaultwarden-backup.timer:

[Unit]
Description=Daily Vaultwarden restic backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=20m
Persistent=true
[Install]
WantedBy=timers.target

Enable it:

systemctl daemon-reload
systemctl enable --now vaultwarden-backup.timer
systemctl list-timers vaultwarden-backup.timer

Confirm it runs under systemd rather than waiting until morning:

systemctl start vaultwarden-backup.service
systemctl show vaultwarden-backup.service -p Result -p ExecMainStatus
Result=success
ExecMainStatus=0

OnCalendar follows the VM clock, which is UTC unless you changed it. Persistent=true runs a missed backup at the next boot, so a VM that was powered off overnight still catches up. Read the last run with journalctl -u vaultwarden-backup.service -n 50.

Step 10: Confirm the bucket holds ciphertext

Section titled “Step 10: Confirm the bucket holds ciphertext”

This step answers the question the missing server-side encryption leaves open. Every restic repository has a small object named config at its root. On the VM, restic decrypts it for you:

restic cat config
{
"version": 2,
"id": "db62f2502541f42cc6af484cc6b9fc04b24498e3dc6430441a4156bcea6553de",
"chunker_polynomial": "395c3db449e5db"
}

Now fetch the same object straight from the bucket. Use the management credentials on your workstation instead of restic:

zcp object-storage object list <object_storage_slug> <your-bucket-name> \
--region os-yul --project default-9
zcp object-storage object download <object_storage_slug> <your-bucket-name> config \
--dest /tmp/restic-config --region os-yul --project default-9
file /tmp/restic-config
head -c 32 /tmp/restic-config | xxd

The listing shows the standard repository layout:

KEY SIZE
config 155
data/00/00d100ce161bbecdd894e17051b75e431d81d8178c3f25c665e27373e1b167ff 1945
data/a3/a31a963e4c25da5fc0a8446cb32840830b6ecf1aa70f26e5145863d6ed13d91c 8081
index/d17710e043e7ba09fa77ab93681d82dc4ca1bfe7a7ad915f0e45c0046987e07e 638
keys/78fd2a6413aa925c0f0fe801933cc68f18568ae82cb7dcede38121e288f25bb4 446
snapshots/f5eb9b2f32fd48703d1f1b4487d33195099250bdeffe625a94149653c66a46bf 379

And the same config object, straight out of the bucket, is unreadable:

/tmp/restic-config: data
00000000: 6498 2898 76cb 76fd 3a6a e81f 1dd5 b9db d.(.v.v.:j......
00000010: eac5 6875 a1b5 b406 59c3 7b4c b264 c344 ..hu....Y.{L.d.D

file reports data, and the hex dump is noise. The same holds for the pack files under data/, which carry the vault contents. Nothing in the bucket is readable without the repository password, and that password never leaves the VM and your offline copy of it. restic encrypts with AES-256 in counter mode and authenticates with Poly1305-AES, so a modified object fails its MAC check instead of decrypting to something plausible.

A backup you have never restored is a hypothesis. Prove it now, while losing the data does not matter.

Delete the password item you saved in Step 5 from the web vault. Then restore the snapshot:

restic snapshots --tag vaultwarden
restic restore latest --target /tmp/vault-restore
restoring snapshot f5eb9b2f of [/var/backups/vaultwarden/stage] to /tmp/vault-restore
Summary: Restored 8 files/dirs (274.046 KiB) in 0:00

restore writes the tree under /tmp/vault-restore/var/backups/vaultwarden/stage. Put the database back with the container stopped, so nothing writes underneath you:

cd /data/vaultwarden
docker compose stop
rsync -a --delete \
--exclude 'ssl/' \
--exclude 'icon_cache/' \
--exclude 'tmp/' \
/tmp/vault-restore/var/backups/vaultwarden/stage/data/ ./data/
chown -R root:root ./data
docker compose start

--delete makes this a point-in-time restore rather than a merge, so attachments, sends, and config.json return to their state at the snapshot, and it removes anything created since. Copying only the database would leave those out of step with it.

The three exclusions repeat the ones the backup script uses, which keeps --delete safe here. rsync does not delete a path it was told to exclude unless you add --delete-excluded, so the live ssl/ directory survives. This matters: ROCKET_TLS in vaultwarden.env points at /data/ssl/cert.pem, the certificate is generated on the VM and never staged, and deleting it stops the container from starting. Vaultwarden regenerates icon_cache/ and tmp/.

vaultwarden.env is in the snapshot too, but leave it alone for a rollback on the same VM. A database restore does not change it, and overwriting it would discard any later edit to DOMAIN or the admin token. Restore it only when rebuilding on a different VM, as below.

Reload the web vault and log in. The item you deleted is back. To check without a browser, read the database directly before and after:

sqlite3 /data/vaultwarden/data/db.sqlite3 'select email from users;'

To rebuild on a fresh VM instead, deploy the same Marketplace image and install restic. Write the same /etc/restic/vaultwarden.env and repository password, then run the restore against the existing repository. Copy rsa_key.pem and config.json across as well. The RSA key signs client tokens, and replacing it logs every client out.

restic restore latest --target /tmp/vault-restore
cp /tmp/vault-restore/var/backups/vaultwarden/stage/vaultwarden.env \
/data/vaultwarden/vaultwarden.env

Destroy the whole stack when you are finished testing:

tofu destroy

Destroy does not complete in one pass. Expect both failures.

Empty the bucket first. A bucket holding a restic repository refuses to delete, and the destroy stops with API error 403 on zcp_object_storage_bucket. Clear it, then destroy again:

zcp object-storage bucket empty <object_storage_slug> <your-bucket-name> \
--region os-yul --project default-9
Removed 9 object/version entries from bucket "vaultwarden-restic-001024".

The public IP goes with its network, not on its own. Because the address is the network’s source-NAT IP, releasing it directly fails with API error 403: Source Nat IP cannot be deleted from the associated network. Hand it to the network destroy instead:

tofu state rm zcp_ip_address.vault
tofu destroy

Deleting the network releases the address with it. Confirm nothing is left billing:

zcp instance list --region yul-1 --project default-9
zcp network list --region yul-1 --project default-9
zcp ip list --region yul-1 --project default-9
zcp object-storage list --region os-yul --project default-9

Everything on the VM times out, including apt-get update. The network has no egress rules, so the VM cannot reach the package archive, GitHub, or the object storage endpoint. Check with zcp egress list --network <network-slug> --region yul-1. An empty list is the problem. Apply the three zcp_egress_rule resources from Step 3. A VM on an auto-created network does not hit this, which is why the CLI tutorials never mention egress.

SSH times out right after the apply. Firewall and port-forward rules activate asynchronously and take about thirty seconds. Confirm both are Active:

zcp firewall list --ip <ip-slug> --region yul-1 --project default-9
zcp portforward list --ip <ip-slug> --region yul-1 --project default-9

An empty firewall list moments after a rule was created usually means the rule has not activated yet. It does not necessarily mean the rule failed.

SSH still times out and the rules are Active. Check that the port forwards exist. Static NAT on the network’s source-NAT IP silently does nothing, so a configuration using zcp_ip_association instead of zcp_port_forward leaves the VM unreachable while reporting success.

API error 500: Private end port is required. A zcp_port_forward is missing public_end_port or private_end_port. Set them equal to the start ports.

The nightly timer fails but the script works by hand. RESTIC_CACHE_DIR is missing from /etc/restic/vaultwarden.env. systemd runs services without $HOME.

Fatal: unable to open config file or a bucket-not-found error. The repository URL names a nonexistent bucket, usually because the platform’s numeric suffix was left off. List the real names with zcp object-storage bucket list <object_storage_slug> --region os-yul.

wrong password or no key found. The repository password does not match. Check that RESTIC_PASSWORD_FILE points at the file you generated, and that nothing has edited that file since.

Uploads fail with InvalidArgument. Default bucket encryption is set on the bucket. Clear it with zcp object-storage bucket encryption disable <object_storage_slug> <your-bucket-name> and check the current setting with encryption status.