Automatically back up one Bitwarden/Vaultwarden vault and mirror it into another — for example, a nightly copy of your self-hosted Vaultwarden into Bitwarden Cloud (or vice-versa). Runs as a scheduled Docker container or a standalone shell script.
Each run exports the source vault to a timestamped, encrypted archive, then replaces the contents of the destination vault with that backup.
- 🗓️ Scheduled sync via cron (Docker) or your own crontab (standalone).
- 🔒 Encrypted local backups — every export is stored as an AES-256 (OpenSSL)
.tar.gz.encarchive, kept for 30 days. - 🔑 Flexible secrets — plaintext env vars, Docker secrets / plain files, or OpenSSL-encrypted files.
- ⚡ Fast restore — clears the destination via the Bitwarden REST API in bulk (batches of 500) instead of slow per-item CLI calls.
- 🛡️ Resilient source login — retries with backoff, surfaces the real CLI error, and auto-falls-back across known-good CLI versions when a release breaks Vaultwarden (see #50).
- 🧱 Multi-arch image —
linux/amd64andlinux/arm64. - 📟 Healthchecks.io ping support.
Note
This tool syncs a single user's personal vault. It does not currently sync Organisations or multiple users.
- How It Works
- Requirements
- Quick Start (Docker)
- Image tags & releases
- Docker Configuration
- Standalone Script
- Server Endpoints
- Configuration Reference
- Testing with a Subset
- Troubleshooting
- License
Backup (source → encrypted archive)
- Log in to the source server with an API key and unlock the vault.
- Export every item to JSON (
bw export). - Compress and encrypt it to
backups/bw_export_<timestamp>.tar.gz.enc(AES-256, OpenSSL). - Prune archives older than 30 days.
Restore (archive → destination)
- Authenticate to the destination identity server and obtain a REST access token.
- Fetch existing cipher/folder IDs via
/sync. - Bulk soft-delete existing ciphers in batches of 500 via
DELETE /ciphers(Bitwarden auto-purges trash after 30 days), and delete folders viaDELETE /folders/{id}. - Import the decrypted backup with
bw import bitwardenjsonin a single call.
REST requests use bounded timeouts and retries, responses are validated before anything is deleted, and cipher deletion falls back to individual requests if the destination has no bulk endpoint.
The import uses a PTY (
script, orexpecton macOS) to satisfy the single master-password prompt that Bitwarden CLI 2026.x introduced for vault data operations.
- A source and destination Bitwarden/Vaultwarden account, each with a
personal API key (
client_id+client_secret) and its master password. - Docker (recommended), or for the standalone script:
bash,openssl,curl,jq,tar,uuidgen(util-linux), Node.js/npm, and either util-linuxscriptorexpect.
-
Grab docker/docker-compose.yml and edit the source/destination accounts, API keys, servers, and passwords.
-
Start it:
docker compose up -d
-
(Optional) Validate against a subset first with
BW_IMPORT_LIMITbefore a full sync.
A minimal configuration:
services:
bitwarden-sync:
image: martadams89/bitwarden-sync:latest
container_name: bitwarden-sync
restart: always
environment:
- CRON_SCHEDULE=0 0 * * *
# Source (e.g. self-hosted Vaultwarden)
- BW_SERVER_SOURCE=https://vault.example.com
- BW_CLIENTID_SOURCE=user.xxxxxxxx
- BW_CLIENTSECRET_SOURCE=xxxxxxxx
- BW_PASS_SOURCE=source-master-password
# Destination (e.g. Bitwarden Cloud)
- BW_SERVER_DEST=https://vault.bitwarden.com
- BW_CLIENTID_DEST=user.yyyyyyyy
- BW_CLIENTSECRET_DEST=yyyyyyyy
- BW_PASS_DEST=dest-master-password
# Password for the encrypted local backup archives
- BW_TAR_PASS=backup-archive-password
volumes:
- ./config/backups:/app/backups
- ./config/bitwarden-cli:/app/data/bitwarden-cliSee docker/docker-compose.yml for the fully commented template, and the Configuration Reference for every variable.
The image is published to both registries (use whichever you prefer):
- Docker Hub —
martadams89/bitwarden-sync - GitHub Container Registry —
ghcr.io/martadams89/bitwarden-sync
Available tags:
| Tag | Points to | Use it when |
|---|---|---|
latest |
The newest stable release | You want the current stable image (default). |
1, 1.2, 1.2.3 |
Semver — major / minor / exact | You want to pin updates (e.g. :1 = all 1.x, :1.2.3 = frozen). |
edge |
The latest main build |
You want the bleeding edge between releases. |
Releases are automated with release-please:
Conventional-Commit messages on main drive the version bump, the CHANGELOG.md,
and a GitHub Release,
which in turn publishes the versioned images. Base-image security patches are also
picked up by a weekly rebuild, so latest/edge stay current even without a release.
For reproducible deploys, pin a major (e.g.
image: martadams89/bitwarden-sync:1) rather thanlatest.
Configure each password (BW_PASS_SOURCE, BW_PASS_DEST, BW_TAR_PASS) with
one of the methods below. Priority: encrypted file → plain file → plaintext
variable.
Option A — Plaintext environment variable (simplest)
environment:
- BW_PASS_SOURCE=mypassword
- BW_PASS_DEST=mypassword
- BW_TAR_PASS=mytarpasswordOption B — Docker secret / plain-text file
Mount a file containing only the password and point to it with a _FILE variable:
echo 'mypassword' > /run/secrets/bw_pass_sourceenvironment:
- BW_PASS_SOURCE_FILE=/run/secrets/bw_pass_source
- BW_PASS_DEST_FILE=/run/secrets/bw_pass_dest
- BW_TAR_PASS_FILE=/run/secrets/bw_tar_pass
volumes:
- /run/secrets:/run/secrets:roOption C — OpenSSL-encrypted files (matches the standalone script)
Generate an encrypted file and a keyfile for each password:
openssl rand -base64 32 > /secrets/bw_source.key
chmod 400 /secrets/bw_source.key
echo 'mypassword' | openssl enc -aes-256-cbc -salt \
-out /secrets/bw_source_pass.enc -pass file:/secrets/bw_source.keyThen point to both files with _ENC_FILE and _KEYFILE variables:
environment:
- BW_PASS_SOURCE_ENC_FILE=/secrets/bw_source_pass.enc
- BW_PASS_SOURCE_KEYFILE=/secrets/bw_source.key
- BW_PASS_DEST_ENC_FILE=/secrets/bw_dest_pass.enc
- BW_PASS_DEST_KEYFILE=/secrets/bw_dest.key
- BW_TAR_PASS_ENC_FILE=/secrets/bw_tar_pass.enc
- BW_TAR_PASS_KEYFILE=/secrets/bw_tar.key
volumes:
- /secrets:/secrets:roBitwarden Cloud sends a "new client logged in" email whenever it sees a fresh device. Persist the CLI app-data directory (which also stores the REST device identifier) so the same device identity is reused across runs:
environment:
- BITWARDENCLI_APPDATA_DIR=/app/data/bitwarden-cli
volumes:
- ./config/bitwarden-cli:/app/data/bitwarden-cliThe first run creates the identifier; later runs reuse it. You can instead pin a fixed identity:
environment:
- BW_DEVICE_IDENTIFIER=bitwarden-sync-production
- BW_DEVICE_NAME=bitwarden-syncKeep
BW_DEVICE_IDENTIFIERstable. Changing it, deleting the persisteddevice-identifierfile, or removing the volume makes Bitwarden see a new client again.
The image installs two Bitwarden CLI versions side by side, because the source and destination have different compatibility needs:
| Wrapper | Used for | Default | Build arg |
|---|---|---|---|
bw-old |
Source (Vaultwarden) login/export | 2025.12.0 |
BW_CLI_OLD_VERSION |
bw-new |
Destination (Bitwarden cloud) | 2025.12.0 |
BW_CLI_NEW_VERSION |
Both are pinned to a known-good version because 2026.x CLIs have broken both
ends: the Vaultwarden source login with
FetchError: ... /identity/connect/token: Premature close (#50),
and the Bitwarden Cloud import with The decryption operation failed /
Failed to encrypt ciphers in batch. 2025.12.0 is the current confirmed-working
version for both. Pinning also makes builds reproducible, and Renovate proposes
bumps (gated by the CLI compatibility test).
Override at runtime (recommended — works on the published image, no rebuild). The entrypoint reinstalls a CLI only when a concrete version differs from the baked-in default, so default startups do no extra work:
environment:
- BW_CLI_OLD_VERSION=2025.12.0 # source / Vaultwarden
- BW_CLI_NEW_VERSION=2025.12.0 # destination / Bitwarden cloudlatest (or unset) keeps the baked-in build; pinning a concrete version triggers
a one-time reinstall at container start (needs network access).
Override at build time (bakes into a locally built image):
docker build -f docker/Dockerfile \
--build-arg BW_CLI_OLD_VERSION=2025.12.0 \
--build-arg BW_CLI_NEW_VERSION=2025.12.0 \
-t bitwarden-sync .The source login (config → login → unlock) is retried with exponential
backoff, and the actual CLI error is logged instead of being swallowed:
environment:
- BW_LOGIN_RETRIES=3 # attempts per CLI version (default 3)
- BW_LOGIN_RETRY_DELAY=5 # initial delay (s), doubles each retry (default 5)Automatic CLI version fallback. No server advertises which CLI version it
supports — Premature close is a transport-level bug in a given CLI build's
bundled Node, so compatibility is empirical. If the source login keeps failing
with a transport error, the container reinstalls the next known-good version
and retries. The installed version is tried first, then each entry in
BW_CLI_OLD_FALLBACK_VERSIONS:
environment:
- BW_CLI_OLD_FALLBACK_VERSIONS=2025.12.0 2024.9.0 # default; space/comma separatedA wrong master password or an auth/API-key error stops immediately — cycling versions cannot fix those. This fallback is Docker-only (it relies on the image's npm-managed CLI).
The destination import has the same safety net: if bw import fails (e.g.
the 2026.x decryption operation failed encrypt regression), the container
reinstalls the next version from BW_CLI_NEW_FALLBACK_VERSIONS, re-logs-in, and
retries the import:
environment:
- BW_CLI_NEW_FALLBACK_VERSIONS=2025.12.0 2024.9.0 # default; space/comma separatedForce a run now (no need to wait for cron):
docker compose exec bitwarden-sync /app/script.sh
# or: docker exec -it bitwarden-sync /app/script.shA flock guard ensures a manual run can't overlap a scheduled one (two
concurrent runs both clearing+importing the destination would be bad). Set
RUN_ON_START=true to run one sync automatically at container start.
Run status. Every run writes a summary line to the logs and a machine-readable
last-run.json into the CLI state directory
(<BITWARDENCLI_APPDATA_DIR>/last-run.json), e.g.:
{
"status": "success",
"stage": "done",
"duration_seconds": 42,
"exit_code": 0,
"backup": { "items": 312, "folders": 9 },
"cli": { "source": "2025.12.0", "destination": "2026.6.0" }
}On failure, status is error and stage shows where it stopped (e.g.
source_login, import) — handy for debugging.
Logs & alerting. The container logs everything to stdout, so docker logs
(or Dozzle / Loki) gives you live logs and history.
Configure HEALTHCHECK_URL / HEALTHCHECK_PING (Healthchecks.io
or self-hosted) for run history and alerting — the container pings start on
launch, the success URL when done, and /fail if a run errors out.
Prefer the standalone script over Docker? It behaves the same but runs directly on a host.
The script invokes bw-old (source) and bw-new (destination) rather than bw
directly, so you can pin different versions for each side. Create the wrappers
once:
Simplest — one version for both
npm install -g @bitwarden/cli@2025.12.0
sudo ln -sf "$(command -v bw)" /usr/local/bin/bw-old
sudo ln -sf "$(command -v bw)" /usr/local/bin/bw-newAdvanced — pin the source and destination separately
# Destination CLI: latest -> bw-new
npm install -g @bitwarden/cli
sudo ln -sf "$(command -v bw)" /usr/local/bin/bw-new
# Source CLI: pinned, isolated under /opt/bw-old -> bw-old
npm install -g --prefix /opt/bw-old @bitwarden/cli@2025.12.0
sudo tee /usr/local/bin/bw-old >/dev/null <<'EOF'
#!/bin/sh
exec /opt/bw-old/bin/bw "$@"
EOF
sudo chmod +x /usr/local/bin/bw-oldThe standalone script reads its passwords from OpenSSL-encrypted files. Create them next to the script:
# Backup (source) password + keyfile
echo 'Password from Backup Source' > bitwarden_backup_password
chmod 400 bitwarden_backup_password
openssl rand -base64 32 > bitwarden_backup_keyfile
chmod 400 bitwarden_backup_keyfile
openssl enc -aes-256-cbc -salt -in bitwarden_backup_password \
-out bitwarden_backup_password.enc -pass file:bitwarden_backup_keyfile
rm -f bitwarden_backup_password
# Restore (destination) password + keyfile
echo 'Password from Backup Destination' > bitwarden_restore_password
chmod 400 bitwarden_restore_password
openssl rand -base64 32 > bitwarden_restore_keyfile
chmod 400 bitwarden_restore_keyfile
openssl enc -aes-256-cbc -salt -in bitwarden_restore_password \
-out bitwarden_restore_password.enc -pass file:bitwarden_restore_keyfile
rm -f bitwarden_restore_passwordEdit the environment variables near the top of bitwarden_sync.sh:
export BW_TAR_PASS=$(openssl enc -d -aes-256-cbc -in bitwarden_backup_password.enc -pass file:bitwarden_backup_keyfile)
# Source
export BW_PASS_SOURCE=$(openssl enc -d -aes-256-cbc -in bitwarden_backup_password.enc -pass file:bitwarden_backup_keyfile)
export BW_CLIENTID_SOURCE=user.xxxxxxxx
export BW_CLIENTSECRET_SOURCE=xxxxxxxx
export BW_SERVER_SOURCE=https://vault.example.com
# Destination
export BW_PASS_DEST=$(openssl enc -d -aes-256-cbc -in bitwarden_restore_password.enc -pass file:bitwarden_restore_keyfile)
export BW_CLIENTID_DEST=user.yyyyyyyy
export BW_CLIENTSECRET_DEST=yyyyyyyy
export BW_SERVER_DEST=https://vault.bitwarden.comThe script stores its REST device identity in .bitwarden-sync/device-identifier
beside the script — keep it to avoid repeated "new client" emails. Override the
state directory with BITWARDEN_SYNC_STATE_DIR, the backups directory with
BW_BACKUP_DIR, or pin a fixed BW_DEVICE_IDENTIFIER.
chmod +x bitwarden_sync.sh
./bitwarden_sync.shBackups land in the backups/ folder as timestamped encrypted archives. To run
on a schedule, add a crontab entry (every 6 hours shown):
0 */6 * * * /path/to/bitwarden_sync.sh > /dev/null 2>&1Normally you set only BW_SERVER_SOURCE / BW_SERVER_DEST; the scripts derive
the identity and API endpoints from them:
| Destination | BW_SERVER_* example |
API endpoint | Identity/token endpoint |
|---|---|---|---|
| Vaultwarden | https://vault.example.com |
https://vault.example.com/api |
https://vault.example.com/identity |
| Self-hosted official Bitwarden | https://vault.example.com |
https://vault.example.com/api |
https://vault.example.com/identity |
| Bitwarden Cloud US | https://vault.bitwarden.com |
https://api.bitwarden.com |
https://identity.bitwarden.com |
| Bitwarden Cloud EU | https://vault.bitwarden.eu |
https://api.bitwarden.eu |
https://identity.bitwarden.eu |
Only override the derived destination URLs for a non-standard reverse proxy that exposes these services at different paths:
environment:
- BW_API_URL_DEST=https://vault.example.com/api
- BW_IDENTITY_URL_DEST=https://vault.example.com/identity| Variable | Scope | Default | Description |
|---|---|---|---|
BW_SERVER_SOURCE / BW_SERVER_DEST |
both | — | Source / destination base URLs |
BW_CLIENTID_SOURCE / BW_CLIENTSECRET_SOURCE |
both | — | Source API key |
BW_CLIENTID_DEST / BW_CLIENTSECRET_DEST |
both | — | Destination API key |
BW_PASS_SOURCE / BW_PASS_DEST |
both | — | Master passwords (also _FILE, _ENC_FILE/_KEYFILE) |
BW_TAR_PASS |
both | — | Encryption password for backup archives (also _FILE, _ENC_FILE/_KEYFILE) |
BW_API_URL_DEST / BW_IDENTITY_URL_DEST |
both | derived | Override destination endpoints (non-standard proxies only) |
CRON_SCHEDULE |
Docker | 57 23 * * * |
Cron expression for the scheduled run |
RUN_ON_START |
Docker | unset | Run one sync at container start, then continue on cron |
BITWARDENCLI_APPDATA_DIR |
Docker | /app/data/bitwarden-cli |
CLI state directory (persist via volume) |
BW_STATUS_FILE |
Docker | <appdata>/last-run.json |
Where the per-run status JSON is written |
BW_LOCK_FILE |
Docker | <appdata>/…sync.lock |
flock file preventing overlapping runs |
BITWARDEN_SYNC_STATE_DIR |
standalone | ./.bitwarden-sync |
State directory beside the script |
BW_BACKUP_DIR |
standalone | ./backups |
Where encrypted archives are written |
BW_DEVICE_IDENTIFIER / BW_DEVICE_NAME |
both | generated | Fixed REST device identity |
BW_CLI_OLD_VERSION / BW_CLI_NEW_VERSION |
Docker | 2025.12.0 / 2025.12.0 |
Source / destination CLI version (build arg + runtime) |
BW_CLI_OLD_FALLBACK_VERSIONS |
Docker | 2025.12.0 2024.9.0 |
Source CLI versions tried on login transport failure |
BW_CLI_NEW_FALLBACK_VERSIONS |
Docker | 2025.12.0 2024.9.0 |
Destination CLI versions tried on import failure |
BW_LOGIN_RETRIES / BW_LOGIN_RETRY_DELAY |
both | 3 / 5 |
Source login retry count / initial backoff (s) |
BW_API_CONNECT_TIMEOUT / BW_API_MAX_TIME / BW_API_RETRIES |
both | 10 / 60 / 3 |
REST curl connect timeout / max time / retries |
BW_IMPORT_LIMIT |
both | unset | Import only N items per type (testing) |
HEALTHCHECK_URL / HEALTHCHECK_PING |
both | unset | Healthchecks.io pings (Docker also pings /fail) |
Set BW_IMPORT_LIMIT=N to import only N items per type (login, secure note,
card, identity) instead of the full vault — useful for validating a setup before
a full sync:
environment:
- BW_IMPORT_LIMIT=1Remove it (or leave it unset) for a full sync.
FetchError: ... Premature closeon source login. A CLI/Vaultwarden transport incompatibility. The container retries and auto-falls-back acrossBW_CLI_OLD_FALLBACK_VERSIONS; if it still fails, pin a known-good version withBW_CLI_OLD_VERSION(see Bitwarden CLI versions).The decryption operation failed/Failed to encrypt ciphers in batchon import. A 2026.x destination-CLI regression. The container auto-falls-back acrossBW_CLI_NEW_FALLBACK_VERSIONS; if needed, pinBW_CLI_NEW_VERSIONto a known-good version (2025.12.0).bw-old: command not found(standalone). Create the CLI wrappers — see Install the CLI wrappers.- "New client logged in" emails. Persist the CLI state directory or set a
fixed
BW_DEVICE_IDENTIFIER— see Persisting CLI state. Logged in but failed to unlock. The master password (BW_PASS_SOURCE/BW_PASS_DEST) is wrong for that server.
MIT © martadams89