Scheduled SQLite backups in one container: consistent online snapshots, integrity checks, encrypted and deduplicated restic storage, S3 uploads, and grandfather-father-son (GFS) retention. The application stays online. No Docker socket, Docker-in-Docker, or external CI runner is required.
Image: ghcr.io/txchen/sqlite-restic-backup. Supported platforms: Linux amd64 and arm64. This does not include 32-bit ARM/v7. Consumers maintain one Compose file, with no local image build, separate backup script, or cron configuration file.
Start with compose.yaml. Configure the database mount and path, S3 endpoint, credentials, and schedule. The latest tag follows master; for production, pin a published release, sha-<full-commit-id>, or image digest. A private GHCR package requires authentication before pulling.
docker compose pull
# Run init only once for a new, dedicated restic repository.
# Skip it when connecting to an existing repository.
docker compose run --rm backup init
docker compose run --rm backup backup
docker compose run --rm backup check
docker compose run --rm backup snapshots
docker compose up -d backupEach backup follows this sequence:
/source/database.sqlite (live application database)
-> SQLite .backup
-> /staging/database.sqlite (consistent snapshot)
-> PRAGMA integrity_check
-> restic encryption, deduplication, and upload
-> restic forget --prune with GFS retention
BusyBox crond handles CRON_SCHEDULE inside the container. Host cron and GitHub Actions are not involved in scheduling backups. After editing Compose, run docker compose up -d to recreate the container and apply the new schedule. GitHub Actions only builds, tests, and publishes the image.
Suppose the application stores app.sqlite in ./app-data on the Docker host. Save this as compose.yaml next to that directory, or add the backup service to the application's existing Compose file:
services:
backup:
image: ghcr.io/txchen/sqlite-restic-backup:latest
restart: unless-stopped
environment:
BACKUP_NAME: my-app
DB_PATH: /source/app.sqlite
TZ: UTC
CRON_SCHEDULE: "0 3 * * *"
KEEP_DAILY: "7"
KEEP_WEEKLY: "4"
KEEP_MONTHLY: "12"
RESTIC_REPOSITORY: s3:https://s3.example.com/backups/my-app
AWS_ACCESS_KEY_ID: "<access-key>"
AWS_SECRET_ACCESS_KEY: "<secret-key>"
RESTIC_PASSWORD: "<backup-encryption-password>"
volumes:
- ./app-data:/source
- ./backup-staging/my-app:/staging
- ./backup-cache/my-app:/root/.cache/restic
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"Replace the endpoint and credential placeholders on the deployment machine. The source directory and database must already exist. Pre-create the S3 bucket and use a dedicated prefix for this service. SeaweedFS uses the same S3 configuration pattern; use its actual S3 endpoint, not its administration URL.
Initialize the restic repository, verify a manual backup, then start the scheduler:
docker compose pull backup
docker compose run --rm backup init # New restic repository only.
docker compose run --rm backup backup
docker compose run --rm backup check
docker compose run --rm backup snapshots
docker compose up -d backup
docker compose logs --tail=100 backup
docker compose psAn existing restic repository does not need init again. Startup does not automatically run a backup, which is why the manual backup comes before enabling the scheduler. For an older Compose installation, use docker-compose and a supported file format such as version: "2.4".
If Forgejo mounts ./data:/data and its database is /data/gitea/forgejo.db, use the complete example above with these service-specific settings:
environment:
BACKUP_NAME: forgejo
DB_PATH: /source/forgejo.db
RESTIC_REPOSITORY: s3:https://s3.example.com/backups/forgejo
# Keep the schedule, retention, and credentials from the complete example.
volumes:
- ./data/gitea:/source
- ./backup-staging/forgejo:/staging
- ./backup-cache/forgejo:/root/.cache/resticThis is a replacement for the corresponding fields, not a second environment section to append. Keep Forgejo running. No ports, Docker socket, or changes to the Forgejo container are required. This protects only its SQLite database; repositories, attachments, configuration, and keys require separate recovery sources.
For daily backups at 09:00 in Los Angeles, with 14 daily, 8 weekly, and 12 monthly recovery points, update the existing environment:
TZ: America/Los_Angeles
CRON_SCHEDULE: "0 9 * * *"
KEEP_DAILY: "14"
KEEP_WEEKLY: "8"
KEEP_MONTHLY: "12"Apply the change with docker compose up -d backup. Backups still run every day; the retention policy keeps fewer recovery points as they age. Use TZ: UTC if you want a schedule unaffected by daylight-saving changes. The health threshold must exceed the longest intended interval between successful backups.
Add another backup service using the same image. Give it a different BACKUP_NAME, source database, S3 repository prefix, and staging directory. For example, backups/wiki with ./backup-staging/wiki is separate from backups/forgejo with ./backup-staging/forgejo. Each container can have its own cron expression and retention policy; no new image build is needed.
| Variable | Default or purpose |
|---|---|
BACKUP_NAME |
Required stable service identifier; letters, digits, underscores, dots, and hyphens only |
DB_PATH |
/source/database.sqlite; absolute database path inside the container |
CRON_SCHEDULE |
0 3 * * *; a five-field numeric cron expression |
TZ |
UTC; timezone used by cron |
KEEP_DAILY |
7; retain one snapshot per day for the last seven days with backups |
KEEP_WEEKLY |
4; retain one snapshot per week for the last four weeks with backups |
KEEP_MONTHLY |
12; retain one snapshot per month for the last twelve months with backups |
BACKUP_TIMEOUT_SECONDS |
1800; total timeout for snapshot creation, upload, and retention |
MAX_BACKUP_AGE_SECONDS |
172800 (48 hours); maximum backup age accepted by the health check |
RESTIC_REPOSITORY |
Required restic destination, such as s3:https://s3.example.com/bucket/service |
RESTIC_PASSWORD |
Backup encryption password; the standard RESTIC_PASSWORD_FILE option also works |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
S3 credentials; other credential providers supported by restic may also be used |
Give each service its own staging directory, stable BACKUP_NAME, and dedicated restic repository in a separate bucket or prefix. The snapshot path stays fixed to avoid accidental retention groups caused by random container hostnames or date-based source filenames.
Retention rules are combined with OR: a snapshot matching any rule is retained, rather than copied into separate daily, weekly, and monthly sets. A retention tier may be 0, but all tiers cannot be zero. Restic keeps the latest snapshot in each period and may additionally retain the oldest snapshot when fewer periods are available. Shared data blocks are deduplicated, so daily snapshots do not require uploading the entire unchanged database every day.
forget removes snapshot references; prune reclaims unreferenced data. Do not apply age-based S3 object deletion to the files inside a restic repository.
- Only ordinary SQLite databases are supported. SQLCipher and databases requiring custom extensions or keys are outside the current scope.
- Mount the database directory so SQLite can access adjacent WAL and shared-memory files. The database connection uses
-readonly, but WAL locking and shared memory can require a writable directory; the example source mount therefore does not use:ro. Mount only the required directory. /stagingcontains an unencrypted snapshot. New files useumask 077; protect the host directory as well. The scheduler currently runs as root.- Examples contain placeholders only. Actual credentials may be configured in the deployment machine's Compose file, but must not be committed or printed. Keep the restic password separately in a password manager; losing it makes backups unrecoverable.
- Use verified TLS for S3. Test upload and restore against the actual SeaweedFS or other S3-compatible endpoint before relying on it.
- This image backs up the database only. Application attachments, Git/LFS data, configuration, and keys need separate recovery sources.
Missing, corrupt, or invalid databases abort the backup. Failed uploads do not trigger retention cleanup. An old staging snapshot is never reported as a new successful backup. A file lock prevents overlapping invocations for the same staging directory; lock contention exits with code 75. The overall timeout bounds snapshot creation, upload, and cleanup.
The last complete success timestamp is stored in /staging/last-success. A failed attempt creates /staging/last-failure, which the next successful backup clears. The Docker health check fails after a backup failure or when the success timestamp is too old. Before the first successful backup, the same maximum-age interval acts as a startup grace period. Restarting the container does not reset these persistent records.
A health check is not an external alert. Connect your monitoring to container health and backup freshness, and periodically test restores.
Cron does not guarantee catch-up after downtime or run immediately on startup, so perform the first backup manually. Local daylight-saving transitions can skip or repeat times; use UTC when fixed wall-clock behavior matters. Tini forwards termination signals and reaps child processes so the scheduler can stop without SIGKILL.
Optional HTTP notifications, inspired by vaultwarden-backup, work with webhooks, Healthchecks.io and similar monitors. All URLs are disabled by default.
Upgrading from v0.1.0: v0.2.0 defaults to POST with a JSON result body.
Set PING_METHOD: GET to retain the previous bodyless GET behavior. No database
or restic repository migration is required.
environment:
BACKUP_NAME: my-db
PING_URL: https://example.com/notifyAfter each backup attempt, the configured URL receives a POST with
Content-Type: application/json and a body like this:
{
"backup_name": "my-db",
"status": "success",
"exit_code": 0,
"timestamp": "2026-09-09T14:00:00Z"
}status is started, success or failure. A start notification has a null
exit_code; completed attempts include the actual exit code (0 for success).
Success means snapshot, upload, retention and success marker all completed.
timestamp is the notification generation time in UTC, not the snapshot time.
There is no separate event field. Database content, credentials and backup logs
are not included.
| Variable | Default and purpose |
|---|---|
PING_URL |
Empty; notify after either success or failure |
PING_URL_WHEN_START |
Empty; notify after acquiring the lock, before validation and backup |
PING_URL_WHEN_SUCCESS |
Empty; notify after the complete backup succeeds |
PING_URL_WHEN_FAILURE |
Empty; notify on validation, snapshot, upload, retention or backup timeout failure |
PING_METHOD |
POST; accepts POST or GET |
PING_TIMEOUT_SECONDS |
10; positive integer timeout per request, including connection |
PING_CONTENT_TYPE |
application/json; POST request content type |
PING_PAYLOAD |
Unset; automatically generate result JSON, or set a literal body shared by all events |
PING_PAYLOAD_WHEN_START |
Unset; override the shared body for start notifications |
PING_PAYLOAD_WHEN_SUCCESS |
Unset; override the shared body for success notifications |
PING_PAYLOAD_WHEN_FAILURE |
Unset; override the shared body for failure notifications |
Set PING_URL_WHEN_START as well as PING_URL to receive both start and completion
notifications. Alternatively, configure PING_URL_WHEN_SUCCESS and
PING_URL_WHEN_FAILURE to use separate result URLs (they may also be identical).
Leave PING_URL unset in that case unless a second completion notification is
wanted: completion is sent after the success/failure notification.
environment:
PING_URL_WHEN_SUCCESS: https://example.com/notify
PING_URL_WHEN_FAILURE: https://example.com/notify
PING_PAYLOAD_WHEN_SUCCESS: '{"text":"my-db backup succeeded"}'
PING_PAYLOAD_WHEN_FAILURE: '{"text":"my-db backup failed"}'Body precedence is: event-specific payload, then PING_PAYLOAD, then automatic
JSON. An explicitly empty string sends an empty body. To get automatic JSON,
leave payload variables unset, rather than setting them to empty strings.
The provided Compose example leaves these variables commented out for this reason.
The completion notification uses PING_PAYLOAD or automatic JSON; it does not
inherit PING_PAYLOAD_WHEN_SUCCESS or PING_PAYLOAD_WHEN_FAILURE.
Custom bodies replace the entire default object. They are sent verbatim, without
JSON validation, template expansion, shell evaluation or added metadata. For text,
set PING_CONTENT_TYPE: text/plain; YAML block scalars support multiline bodies.
A leading @ is literal text, not a file reference. Environment variables cannot
carry NUL bytes. GET sends no body or content-type header; an explicit GET with a
nonempty custom payload is rejected instead of silently discarding the body.
Supply token-bearing URL values out of band using the optional entries in
compose.yaml (replace the placeholder with your check's ping URL):
PING_URL_WHEN_START=https://hc-ping.com/<check-uuid>/start
PING_URL_WHEN_SUCCESS=https://hc-ping.com/<check-uuid>
PING_URL_WHEN_FAILURE=https://hc-ping.com/<check-uuid>/failLeave PING_URL unset: sending a completion request to the base Healthchecks URL
after failure would incorrectly mark the check successful. POST bodies may be
recorded by your monitoring provider; check its logging policy. If the receiver
requires the previous GET behavior, set PING_METHOD=GET.
Only HTTP(S) is accepted. HTTPS verifies certificates; redirects are not followed, and only 2xx responses count as delivered. Requests have no retries. URLs, request bodies and response bodies are not logged. URL/config and body are passed to curl through separate file descriptors rather than command arguments. Keep sensitive values out of Git.
Delivery or notification configuration failures produce a warning but never change
the backup exit code or health markers. Notifications run outside
BACKUP_TIMEOUT_SECONDS; a run can take up to three additional
PING_TIMEOUT_SECONDS intervals. The staging lock is held until notifications
finish. A skipped overlapping invocation (exit 75) sends no notifications.
Abrupt process/container termination cannot guarantee a failure or completion ping;
configure monitoring to alert on missing expected success notifications as well.
Test a configured notification without accessing the database or restic repository:
docker compose run --rm backup ping start
docker compose run --rm backup ping success
docker compose run --rm backup ping failure
docker compose run --rm backup ping completionThese commands send real HTTP requests using simulated results: start uses
started/null, success and completion use success/0, and failure uses failure/1.
They use BACKUP_NAME if set, otherwise an empty string. Configured custom bodies
apply to tests too. A disabled URL is a successful no-op; invalid configuration or
delivery failure exits nonzero. Arbitrary curl options and message templates are
not supported.
docker compose run --rm backup snapshots
# Restore into a new isolated directory, never over a live database.
docker compose run --rm -v /absolute/isolated-restore:/restore backup \
restore latest --target /restoreTo select an older recovery point, copy its ID from snapshots and replace latest:
docker compose run --rm -v /absolute/isolated-restore:/restore backup \
restore <snapshot-id> --target /restoreThe restored database is /absolute/isolated-restore/staging/database.sqlite. Check its integrity, then validate application behavior in an isolated environment. Restic stores an encrypted, deduplicated repository, not individual downloadable .db objects; restoring requires restic and the repository password.
The image accepts init, check, snapshots, restore, and dump; additional arguments are passed through to restic. Periodically run check --read-data and application-level restore drills. Take an extra manual backup before application upgrades and record the matching application version.
GitHub Actions builds and runs integration tests on native Linux amd64 and arm64 runners. Tests cover online WAL snapshots and restores, invalid databases, incorrect repository passwords, overlapping backups, retention protection, cron, health reporting, and graceful shutdown.
After both architectures pass, Buildx publishes a multi-platform image to GHCR. Docker selects the matching architecture when pulling. CI uses the short-lived GITHUB_TOKEN with packages: write; no personal token is required. Third-party Actions are pinned to release commit SHAs.
- Pull requests: test only; no image push or package write permission.
master: test and publishlatestplussha-<full-commit-id>.- Version tags such as
v0.1.0: test and publish0.1.0plus a commit tag.latestis updated only by the default branch, so publishing an older release tag cannot move it backwards. - Manual dispatch: test and publish the selected commit; the default branch also updates
latest.
After the first publication, check the package visibility in GitHub Packages. New GHCR packages may default to private even when the source repository is public. Change the package itself to Public to allow anonymous pulls; repository visibility is a separate setting.
For local maintenance, build and test from an isolated directory:
docker build -t sqlite-restic-backup:test /path/to/sqlite-restic-backup
python3 /path/to/sqlite-restic-backup/tests/integration.py \
--image sqlite-restic-backup:test --platform linux/amd64 \
--workdir /path/to/disposable-testsHTTP callback tests also cover lifecycle order, lock skips, delivery errors, redirects, timeouts and URL handling. Tests use disposable SQLite databases and a local restic repository, without production credentials or S3. They stop test containers and remove their networks, retaining test files for inspection. GitHub-hosted runner disposal removes CI artifacts. Real S3 endpoints still require separate integration validation.
The base image pins restic 0.19.1; Alpine tools are installed during the build. Record the image digest in addition to release tags. Before rolling back the backup image, confirm that the older restic version supports the existing repository format.