Skip to content

Backing up and restoring CashPilot

Two files decide whether a restore works. If you read nothing else on this page, read this box.

.fernet_key is not recoverable

Every credential you have entered (provider passwords, API keys, session cookies) is encrypted in the database with the key in /data/.fernet_key. The database alone is not enough. Restore cashpilot.db without that key and every stored credential is permanently undecryptable. There is no recovery, no reset, no support path: the plaintext does not exist anywhere else.

Back up both, always, together.

This page covers the CashPilot UI's own data. Backing up the node identities your services generate (the Mysterium keystore and friends) is a separate job and lives in Backing Up Node Identities.


What is in /data

The UI's /data volume (cashpilot_data in the shipped compose files):

Path What it is Lose it and…
cashpilot.db SQLite: earnings history, deployments, workers, users, settings you lose your history
.fernet_key Encrypts every stored credential, at rest every credential is permanently unreadable
.secret_key Signs login sessions everyone is logged out once; nothing else

.secret_key may not exist, and that is normal. It is only written when CASHPILOT_SECRET_KEY is unset; set the variable and the file is never created. Verified on a live install: /data held .fernet_key and nothing else.

.secret_key and .fernet_key are different keys with different jobs, and they are easy to confuse because both are set by an environment variable with a similar name. CASHPILOT_SECRET_KEY signs sessions. CASHPILOT_ENCRYPTION_KEY is the credential key. Restoring the wrong one gets you a working login and unreadable credentials.

There are two other volumes, and neither is precious:

  • cashpilot_fleet_owned (/fleet/.fleet_key): the shared enrollment key. Shared by the UI and its co-located worker. If you lose it, set CASHPILOT_API_KEY explicitly or let it regenerate and re-enroll the workers.
  • cashpilot_worker_data_owned (/data/.worker_key): that worker's own issued key. Lose it and the worker re-enrolls on its next heartbeat. Nothing is destroyed.

    The worker's /data is a separate volume from the UI's, deliberately. The worker drives Docker, which is close to root on the host: through the socket proxy with the shipped compose files, and with the raw socket, which is root, in the app-store listings; it has no business being able to read the credential store. Do not consolidate them.


Backup

Stop nothing. SQLite is in WAL mode, but a plain file copy of a live database can still catch a torn write, so use SQLite's own backup command for the database and copy the keys alongside it.

# Everything that matters, into one dated directory.
OUT="cashpilot-backup-$(date +%Y%m%d)"

# The set is built in a .part directory. It replaces the last good backup only
# when every step worked, so a failed run leaves that backup as it was.
# The database comes out on stdout, because /tmp in the container is a tmpfs
# and docker cp cannot read it. SQLite copies it consistently, without stopping
# the container. Then THE key: without it the database is half a backup.
# The session key is only present if CASHPILOT_SECRET_KEY is unset, so its
# absence is fine.
rm -rf "$OUT.part" && mkdir -p "$OUT.part" &&
docker exec cashpilot-ui python -c \
  'import sqlite3,sys; s=sqlite3.connect("/data/cashpilot.db"); d=sqlite3.connect(":memory:"); s.backup(d); sys.stdout.buffer.write(d.serialize())' \
  > "$OUT.part/cashpilot.db" &&
docker cp cashpilot-ui:/data/.fernet_key "$OUT.part/.fernet_key" &&
{ docker cp cashpilot-ui:/data/.secret_key "$OUT.part/.secret_key" 2>/dev/null \
  || echo "no .secret_key (CASHPILOT_SECRET_KEY is set in the environment), fine"; } &&
rm -rf "$OUT" && mv "$OUT.part" "$OUT" &&
chmod 600 "$OUT"/.*key &&
ls -la "$OUT"

The backup is now as sensitive as the server

.fernet_key plus cashpilot.db is every provider credential you own, in a directory. Treat that pair the way you would treat the passwords themselves: encrypted storage, restricted permissions, and not in a git repository.

Verify it, or it is not a backup

An untested backup is a hope. Check that the two files are non-empty and that the database opens:

test -s "$OUT/.fernet_key" && echo "key present"   # the one that cannot be regenerated
sqlite3 "$OUT/cashpilot.db" "PRAGMA integrity_check;"   # expect: ok
sqlite3 "$OUT/cashpilot.db" "SELECT count(*) FROM earnings;"

Restore

Onto a fresh install. It does not matter if the fresh UI has already started and made its own keys: the restore replaces them.

Compose names the volume after your project (for example cashpilot_cashpilot_data), not plain cashpilot_data. So the restore never names the volume. It writes through the UI container's own /data mount, with the CashPilot image you already have.

If the backup may have leaked, or you rotated the session key after you took it, do not let its session key come back: anyone who holds that copy can sign a login cookie. Uncomment the rm line in the block below. It runs after the copy and before the UI starts. It also removes a key that the old volume still held, and the UI then writes a new key when it starts. If you set CASHPILOT_SECRET_KEY in the environment, there is no key file to remove: that value is the leaked key, so give it a new value before the UI starts. A restored database also brings back the accounts and passwords it held, so repeat the account check and the password resets from the FAQ afterwards.

Set OUT to the backup directory first. The OUT from the Backup steps exists only in the shell that ran them. A relative or an absolute path both work.

The restore checks that cashpilot.db and .fernet_key are both in the backup before it stops the UI or changes anything. It copies the files into the volume under temporary names, and puts them in place only when every copy worked. If any step fails, the UI does not start.

# Set OUT to the backup directory first, for example:
# OUT=cashpilot-backup-20261111

# On a new machine, create the containers first: the restore needs the UI
# container to exist. Skip this if CashPilot is already installed.
docker compose create

# Put all three files back TOGETHER, into the volume the UI really mounts.
BACKUP="$(cd "${OUT:?set OUT to the backup directory}" && pwd)" &&
{ test -s "$BACKUP/cashpilot.db" && test -s "$BACKUP/.fernet_key" ||
  { echo "cashpilot.db or .fernet_key is missing from the backup; nothing changed" >&2; false; }; } &&
docker compose stop cashpilot-ui &&
docker run --rm --volumes-from cashpilot-ui -v "$BACKUP":/backup:ro \
  --user 0 --entrypoint sh "$(docker inspect -f '{{.Config.Image}}' cashpilot-ui)" -c '
  set -e
  # Copy under temporary names first. A failed copy leaves the volume as it was.
  trap "rm -f /data/cashpilot.db.restore /data/.fernet_key.restore /data/.secret_key.restore" EXIT
  cp /backup/cashpilot.db /data/cashpilot.db.restore
  cp /backup/.fernet_key  /data/.fernet_key.restore
  # Optional: absent whenever CASHPILOT_SECRET_KEY is set.
  if [ -f /backup/.secret_key ]; then cp /backup/.secret_key /data/.secret_key.restore; fi
  # A database log left by the install you replace would be replayed over the backup.
  rm -f /data/cashpilot.db-wal /data/cashpilot.db-shm
  mv /data/cashpilot.db.restore /data/cashpilot.db
  mv /data/.fernet_key.restore /data/.fernet_key
  if [ -f /data/.secret_key.restore ]; then mv /data/.secret_key.restore /data/.secret_key; fi
  # Only if the backup may have leaked, or the session key was rotated after it:
  # rm -f /data/.secret_key
  chmod 600 /data/.*_key
  # The app runs as the owner of /data: uid 1000, or the user you set with --user.
  # A new bind mount is still owned by root, so use uid 1000 there.
  owner="$(stat -c %u:%g /data)"
  case "$owner" in 0:*) owner=1000:0 ;; esac
  chown "$owner" /data/cashpilot.db /data/.*_key' &&
docker compose up -d
docker logs -f cashpilot-ui

If the UI already runs with the old key, replace the key and restart:

docker exec cashpilot-ui python -m app.users rotate-session-key && docker restart cashpilot-ui

What the logs should say

Two lines tell you the restore worked.

The schema line reports the version and any migration that ran. Expect a migration if the backup came from an older release, and none if it did not. <N> is the schema version of the release you run, and it grows with releases:

Schema at version <N>; no migration needed this boot.
Schema now at version <N> (was 0). Migrations applied this boot: earnings.source

And there should be no decryption error. If the key did not come across you will see this, once per affected credential:

Failed to decrypt a stored credential: the credential-encryption key
(CASHPILOT_ENCRYPTION_KEY / /data/.fernet_key) does not match the key this
value was encrypted with.

That message means exactly what it says: the database was restored and the key was not. Put the original .fernet_key back. The credentials cannot be recovered any other way.

Restoring the key by environment variable instead

If you kept the key as a secret rather than a file, CASHPILOT_ENCRYPTION_KEY is adopted only when no key file exists, which covers both a fresh install and a restore onto an empty volume:

environment:
  - CASHPILOT_ENCRYPTION_KEY=<the base64 key from your backup>

The file always wins where it exists. That ordering is deliberate: a running install must never have its key silently replaced by a stale environment variable.


Things CashPilot refuses to do, and why

Three refusals you may run into. All three are the software declining to destroy something.

It will not overwrite an unreadable .fernet_key. If the file exists but is corrupt or the wrong length, startup fails rather than generating a replacement, because generating one would destroy the only artifact that could still decrypt your credentials. Restore the file, or move it aside and re-enter the credentials, which is a real choice you are allowed to make.

It will not start if the key cannot be persisted. A read-only or unwritable /data means every credential entered during that run becomes undecryptable the moment the container restarts. Failing at startup is kinder than failing silently, hours later. If you really want a throwaway instance, set CASHPILOT_ALLOW_EPHEMERAL_KEY=true and it starts with a key that dies with the process.

A failed decrypt is logged as an ERROR, not a warning. This is unattended software. The downstream symptom is a provider authentication failure that points nowhere near the real cause, so it is logged loudly, at the point where the cause is still visible.


Upgrades

The images pin major.minor in the shipped compose files, so docker compose pull picks up patch fixes and never crosses a minor boundary without an edit.

Before a minor or major upgrade: take the backup above. Migrations are forward-only (there is no down migration), so the way back is a restore. The schema line at startup tells you whether anything actually changed.