# Backups with Litestream

By default cronpad runs on plain SQLite with no replication. For continuous backups, its container image includes Litestream, which you turn on with one environment variable. It then streams every change to both SQLite files to object storage (S3, GCS, Azure Blob, any S3-compatible service, SFTP) within about a second, and on startup, if the data volume is empty, the container restores the latest copy automatically. You can also restore to any point in time within your retention window. The queue file (`cronpad-queue.db`) needs no backup: cronpad rebuilds it from the restored database.

## Enable replication

Set the replica location and credentials on the container:

```sh
LITESTREAM_REPLICA_URL=s3://my-bucket/cronpad
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
# For S3-compatible services such as SeaweedFS, Garage or Cloudflare R2:
# LITESTREAM_ENDPOINT=https://<account>.r2.cloudflarestorage.com
```

When `LITESTREAM_REPLICA_URL` is set, the entrypoint runs cronpad under Litestream with this configuration (`deploy/litestream.yml`):

```yaml
dbs:
  - path: /data/cronpad.db
    replicas:
      - url: ${LITESTREAM_REPLICA_URL}/control
  - path: /data/history.db
    replicas:
      - url: ${LITESTREAM_REPLICA_URL}/history
```

Without it, cronpad runs as plain SQLite with no replication; see [SQLite only](#sqlite-only-the-default).

:::note
Litestream's configuration format has changed between major versions. The file in `deploy/` matches the Litestream version bundled in the image; check the Litestream documentation if you run your own.
:::

## SQLite only (the default)

With `LITESTREAM_REPLICA_URL` unset, the entrypoint runs plain `cronpad serve`: no restore on start, no replication. Startup logs one INFO line, `running SQLite only; set LITESTREAM_REPLICA_URL to replicate`. The bare binary always runs this way, since Litestream is only bundled in the container image.

Earlier releases warned when no replica was set and offered `LITESTREAM_DISABLED=true` to silence it. That variable is no longer used: if it's still set, startup mentions it at INFO level and otherwise ignores it.

What it means: your data lives only in the two SQLite files, `cronpad.db` (jobs, keys, settings) and `history.db` (run history), on the data volume. Nothing is streamed anywhere, so a lost disk loses everything written since your last copy. The queue file still needs no backup.

It's a reasonable choice for local development, evaluation, and small installs where losing a day of history is acceptable or the host already snapshots its disks. Keep a separate copy of `secrets.encryption_key` regardless.

Simple alternatives to continuous replication:

- **Scheduled `sqlite3 .backup`.** Back up **both** databases, for example from cron or a systemd timer:

  ```sh
  sqlite3 /data/cronpad.db ".backup '/backups/cronpad-$(date +%F).db'"
  sqlite3 /data/history.db ".backup '/backups/history-$(date +%F).db'"
  ```

  `.backup` takes a consistent snapshot while cronpad is running. To restore, stop cronpad, put the files back as `cronpad.db` and `history.db`, and delete any leftover `-wal` and `-shm` files next to them.
- **Volume or disk snapshots** from your cloud provider or filesystem (ZFS, LVM, EBS), taken as a single atomic snapshot of the volume holding both files.

:::warning
Don't copy a live database with `cp`, `rsync` or a file-level backup tool. cronpad uses WAL mode, so recent writes sit in a `-wal` file; copying only the main file, or copying the files at different moments, produces a corrupt or inconsistent backup. Use `.backup`, a volume snapshot, or stop cronpad first.
:::

## Automatic restore on start

On start, the container:

1. Restores `cronpad.db` from the replica if the file doesn't exist and a replica does.
2. Restores `history.db` the same way, into a temporary file. If that fails, takes more than 5 seconds, or the replica is missing, cronpad starts with an empty history and logs an error. Set `LITESTREAM_HISTORY_RESTORE_TIMEOUT` to change this bound. A partial restore is never opened.
3. Starts replication and cronpad.

So recovering from a lost server is: start a new container with the same `LITESTREAM_REPLICA_URL` and an empty volume.

The shell-free container runs `cronpad entrypoint`, which performs these steps and replaces itself with `litestream replicate -exec "cronpad serve"`. The bundled CLI is pinned to Litestream 0.3.13; the optional embedded library is omitted because its API is not stable. Control restore errors stop startup to avoid scheduling from an incomplete control database.

## Manual and point-in-time restore

Stop cronpad first, then:

```sh
# Latest state
litestream restore -o /data/cronpad.db "$LITESTREAM_REPLICA_URL/control"
litestream restore -o /data/history.db "$LITESTREAM_REPLICA_URL/history"

# As of a moment in the past
litestream restore -timestamp 2026-11-10T03:00:00Z -o /data/cronpad.db "$LITESTREAM_REPLICA_URL/control"
```

After a point-in-time restore, cronpad's [misfire policy](../schedules/misfires-and-overlap.md) decides what happens to runs that came due between the restore point and now.

## What else to keep safe

- **`secrets.encryption_key`.** Store it in your secret manager. Restored [secrets](../secrets/index.md) can't be decrypted without it.
- **OAuth client secrets and SMTP credentials**, which live in your config or environment, not the database.

## Test your restores

Run `tools/litestream-restore-test.sh` from the repository for a local `file://` recovery exercise. It downloads the pinned Litestream binary into scratch only when missing, creates a job and runs, destroys the whole data directory (including the queue file), then verifies restore and resumed scheduling. With the Litestream binary cached it runs without network access.

Restore into a scratch directory regularly and start a throwaway cronpad on it. Its queue file is created inside the scratch directory, so it can't touch your production server's queue. It will schedule the restored jobs, though, so start it with `CRONPAD_SINKS__WEBHOOK__ENABLED=false` and `CRONPAD_SINKS__COMMAND__ENABLED=false`: runs then fail with "sink disabled or unavailable" instead of reaching your real endpoints. A backup you haven't restored is a hope, not a backup.