Usage:
docker run --rm \
-e CLOUDFLARE_API_TOKEN="<Cloudflare API token with D1 permissions>" \
-e CLOUDFLARE_ACCOUNT_ID="<Cloudflare account Id>" \
-e DATABASE_ID="<Database UUID>" \
-e DATABASE_NAME="<database name>" \
-e FILE_PREFIX="<filename prefix for backup files>" \
-v <path to backup file storage>:/tmp/backup \
ghcr.io/schack/cloudflare-d1-backup
The container runs as the non-root node user (uid 1000), so the host
directory mounted at /tmp/backup must be writable by uid 1000:
chown 1000:1000 <path to backup file storage>
The entrypoint verifies this before starting the export and exits immediately if the mount is missing or unwritable. The export makes the database unavailable to serve queries while it runs, so failing up front is deliberate.
Chowning to uid 1000 is not always wanted. Synology DSM, for example, assigns user uids from 1024 upwards, so uid 1000 matches no account and the folder shows up with an unknown owner in File Station. Run the container as the directory's current owner instead:
docker run ... \
--user "$(stat -c '%u:%g' <path to backup file storage>)" \
-e HOME=/tmp \
...
Overriding HOME is required, not optional. Docker resolves HOME from
/etc/passwd, so a uid with no entry in the image falls back to /, which is
not writable. wrangler writes its logs to $HOME/.config/.wrangler/logs and
would fail there even once the backup directory itself is writable. The
container's /tmp is mode 1777 and works for any uid.
| Variable | Required | Description |
|---|---|---|
CLOUDFLARE_API_TOKEN |
yes | API token with D1 read permission. |
CLOUDFLARE_ACCOUNT_ID |
yes | Cloudflare account ID. |
DATABASE_ID |
yes | D1 database UUID. |
DATABASE_NAME |
yes | D1 database name. |
FILE_PREFIX |
no | Backup filename prefix. Defaults to d1-database. |
TABLES |
no | Space-separated list of tables to export. When unset, all tables are exported. |
wrangler d1 export doesn't handle SQLite virtual tables cleanly (FTS5,
R-Tree, etc.) — it tries to dump their internal shadow tables as regular
tables and fails. Set TABLES to the list of real user tables you want
backed up; everything else is skipped:
-e TABLES="users posts comments"
Virtual table indexes are derived data — they regenerate automatically when you re-apply your schema migrations and re-import the row data, so excluding them from the backup is safe.
Every published image is multi-arch (linux/amd64 + linux/arm64), signed with
Sigstore cosign (keyless), and ships SBOM + SLSA provenance attestations.
Verify the signature (https://docs.sigstore.dev/cosign/verifying/verify/):
cosign verify ghcr.io/schack/cloudflare-d1-backup:latest \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/schack/cloudflare-d1-backup/\.github/workflows/docker-image\.yml@.*'
Inspect the attached attestations and platforms:
docker buildx imagetools inspect ghcr.io/schack/cloudflare-d1-backup:latest
cosign download sbom ghcr.io/schack/cloudflare-d1-backup:latest
| Tag | Mutability | Use it for |
|---|---|---|
latest |
rolling | always the newest build |
<wrangler-version> (e.g. 4.94.0) |
rolling | the newest build bundling that wrangler version — it keeps receiving base-image and dependency security rebuilds within the same version line |
sha-<commit> |
immutable | a specific build |
Each push to main builds and publishes an image. The <wrangler-version> tag
matches the bundled wrangler release and moves forward as non-wrangler
changes (base image, dependency, or workflow updates) are merged, so it is not a
fixed point. A GitHub Release v<wrangler-version> is cut once, marking when
that version line started.
For reproducible deployments, pin by digest (or by an immutable
sha-<commit> tag), not by latest or <wrangler-version>:
docker run ... ghcr.io/schack/cloudflare-d1-backup@sha256:<digest>
Resolve the current digest with docker buildx imagetools inspect.
If you do track a moving tag, remember that docker run reuses a locally
cached image and will not re-pull one on its own. A host can sit on a
months-old image and then take every accumulated change at once the first time
it pulls, which makes whatever moved last look like the cause. Add
--pull always, or pull on a schedule, so changes arrive one at a time.
See SECURITY.md for the vulnerability disclosure policy.