Daily encrypted Bitwarden and Vaultwarden backups, with an optional mirror into a second vault
1.2K
bitwarden-sync runs once a day and does one or both of these jobs for a Bitwarden or Vaultwarden account:
Backups are on by default. Setting any DEST_BW_* variable turns the mirror on.
Each example below has two files. Put docker-compose.yml and .env in the same folder, then start the container:
docker compose up -d
docker compose logs -f
The container checks its settings when it starts, including that the backups folder is writable and that the source and destination are different accounts, then logs the time of the next run. A missing or invalid setting stops the container with a message that names the problem. To run once right away, use docker compose run --rm -e RUN_AT= bitwarden-sync.
The client ID and secret for each account come from the web vault, under Settings, Security, Keys, View API key.
This example writes an encrypted backup every night at 03:00 and keeps the last 30.
docker-compose.yml:
services:
bitwarden-sync:
image: cuza/bitwarden-sync:latest
restart: unless-stopped
env_file: .env
tmpfs: /tmp
volumes:
- ./backups:/backups
.env:
SOURCE_BW_SERVER=https://vault.example.com
SOURCE_BW_CLIENTID=user.00000000-0000-0000-0000-000000000000
SOURCE_BW_CLIENTSECRET=your-api-key-secret
SOURCE_BW_PASSWORD=your-master-password
BACKUP_PASSWORD=a-long-passphrase-for-the-backup-files
BACKUP_KEEP=30
RUN_AT=03:00
TZ=America/Toronto
Run mkdir -p backups before the first start, so the folder belongs to your user and not to root.
This example copies the personal vault of vault.example.com into a bitwarden.com account every night and writes no backup files. It needs no volumes: section.
docker-compose.yml:
services:
bitwarden-sync:
image: cuza/bitwarden-sync:latest
restart: unless-stopped
env_file: .env
tmpfs: /tmp
.env:
SOURCE_BW_SERVER=https://vault.example.com
SOURCE_BW_CLIENTID=user.00000000-0000-0000-0000-000000000000
SOURCE_BW_CLIENTSECRET=your-api-key-secret
SOURCE_BW_PASSWORD=your-master-password
DEST_BW_SERVER=https://vault.bitwarden.com
DEST_BW_CLIENTID=user.11111111-1111-1111-1111-111111111111
DEST_BW_CLIENTSECRET=other-api-key-secret
DEST_BW_PASSWORD=other-master-password
SKIP_BACKUP=true
RUN_AT=03:00
TZ=America/Toronto
This example writes the backup first and then updates the mirror. If the mirror step fails, that night's backup still exists.
docker-compose.yml is the same as in "Backup only". .env:
SOURCE_BW_SERVER=https://vault.example.com
SOURCE_BW_CLIENTID=user.00000000-0000-0000-0000-000000000000
SOURCE_BW_CLIENTSECRET=your-api-key-secret
SOURCE_BW_PASSWORD=your-master-password
BACKUP_PASSWORD=a-long-passphrase-for-the-backup-files
BACKUP_KEEP=30
DEST_BW_SERVER=https://vault.bitwarden.com
DEST_BW_CLIENTID=user.11111111-1111-1111-1111-111111111111
DEST_BW_CLIENTSECRET=other-api-key-secret
DEST_BW_PASSWORD=other-master-password
RUN_AT=03:00
TZ=America/Toronto
| Variable | Default | Purpose |
|---|---|---|
SOURCE_BW_SERVER | https://vault.bitwarden.com | URL of the vault to back up and copy from. |
SOURCE_BW_CLIENTID, SOURCE_BW_CLIENTSECRET | required | API key of the source account. |
SOURCE_BW_PASSWORD | required | Master password of the source account. |
BACKUP_PASSWORD | required unless SKIP_BACKUP=true | Passphrase that encrypts the backup files. |
SKIP_BACKUP | unset | Set to true to turn backups off. The mirror must be on. |
BACKUP_KEEP | 30 | Number of backup runs to keep. 0 keeps every run. |
DEST_BW_SERVER | https://vault.bitwarden.com | URL of the vault to mirror into. |
DEST_BW_CLIENTID, DEST_BW_CLIENTSECRET, DEST_BW_PASSWORD | unset | API key and master password of the account to mirror into. Setting any DEST_BW_* variable turns the mirror on. |
RUN_AT | 03:00 | Daily run time as HH:MM. An empty value runs once and exits. |
TZ | UTC | Time zone for RUN_AT, such as America/New_York. |
Keep all settings in .env. A value set under environment: in the compose file overrides the same variable in .env. Write values without quotes, because docker run --env-file keeps quotes as part of the value.
Each run writes a folder named after its start time, such as backups/2026-10-23T030000/. The folder holds personal.json and one org-<id>.json file per organization. If you own or administer an organization, its export must succeed or the run fails. If you are an ordinary member and the export fails, which usually means the organization doesn't grant you export permission, the run skips it and logs a warning.
The script writes each run into a temporary folder and renames it only after every export succeeds. A failed run leaves the older backups as they were. If the source vault returns zero items, the run stops before it writes or deletes anything.
To restore a backup, open any Bitwarden or Vaultwarden web vault and go to Tools, Import data. Choose "Bitwarden (json)", upload the file and enter BACKUP_PASSWORD. Keep that passphrase somewhere outside the vault, because nobody can open the backup files without it.
The mirror copies personal items and folders. It notes which personal items and folders the destination holds, imports a plain JSON export of the source, and then deletes the noted items with the server's bulk-delete API, 500 items per request. If the import fails, the destination keeps its previous copy. If a delete fails, the destination holds duplicates until the next run removes them. Organization items stay untouched on both sides. If the source export has no personal items, the run stops before it changes the destination.
The plain export lives in /tmp. The compose file mounts /tmp as tmpfs, so the export stays in memory, and the script deletes it when the run ends.
Every run re-imports the whole personal vault, so item IDs in the destination change each time, and edits made in the destination are lost on the next run.
The Bitwarden export format leaves out file attachments and Sends, so this image doesn't back them up or copy them.
The image uses node:24-alpine and Bitwarden CLI 2026.10.0, with builds for linux/amd64 and linux/arm64. It runs as the node user with uid 1000, so the backups folder must be writable by that uid. dumb-init runs as the first process, so docker stop ends the container at once.
Content type
Image
Digest
sha256:54140d987…
Size
77.4 MB
Last updated
11 days ago
docker pull cuza/bitwarden-sync