Backups
Overview
Safe Harbor's backup sidecar creates a portable archive of the application database and uploaded files. The sidecar runs the same Flask management command used for manual backups, but it is kept in a separate Compose service so scheduled backup work does not run inside the web request process.
Scheduled backups run through the backup compose profile. When the profile is enabled, ofelia watches the backups service labels and executes flask safeharbor backup on the configured cron schedule. Output lands in /backups/ inside the container, which is mounted to ./backups/ on the host.
What's included
Each backup is one tarball named:
./backups/safeharbor-backup-<UTC>.tar
The timestamp is generated in UTC with second precision, for example:
safeharbor-backup-2026-05-07T03-00-00Z.tar
The tarball contains:
| Path | Description |
|---|---|
db.dump |
PostgreSQL custom-format dump created by pg_dump -F c. |
uploads/ |
The full Safe Harbor uploads tree from the application upload volume. |
The command writes to a temporary *.tmp archive first, then atomically replaces the final tarball path when the archive is complete.
Configuration
Configure scheduled backup behavior in .env.
| Var | Default | Description |
|---|---|---|
BACKUP_SCHEDULE |
0 0 3 * * * |
ofelia 6-field cron expression (default = daily 03:00 UTC) |
BACKUP_RETENTION_DAILY |
7 |
Keep this many recent tarballs regardless of weekday |
BACKUP_RETENTION_WEEKLY |
4 |
Additionally keep one tarball per ISO week, up to this many weeks |
BACKUP_SCHEDULE uses ofelia's six-field cron syntax:
second minute hour day-of-month month day-of-week
The default value runs once per day at 03:00 UTC.
Retention settings are read by the backup command after each successful archive is written. They apply only to files matching Safe Harbor's backup filename pattern:
safeharbor-backup-YYYY-MM-DDTHH-MM-SSZ.tar
Enabling scheduled backups
Enable the backup compose profile in .env:
COMPOSE_PROFILES=backup
If another optional profile is already enabled, use a comma-separated list:
COMPOSE_PROFILES=tunnel,backup
Then start or refresh the stack:
docker compose up -d
Compose starts the ofelia scheduler and the idle backups sidecar whenever the backup profile is active. Ofelia then executes the labeled backup job inside the backups container according to BACKUP_SCHEDULE.
Confirm the profile services are present:
docker compose --profile backup ps ofelia backups
Manual on-demand backup
Run the backup command directly in the sidecar when you want a backup immediately:
docker compose --profile backup exec backups flask safeharbor backup
Manual backups use the same output path
The command writes to /backups/ inside the container. In the standard Compose stack that is the host directory ./backups/, so the new tarball appears beside scheduled backups.
The command prints the output path and final archive size. If retention deletes older tarballs during the same run, it also prints the number of pruned files.
Retention algorithm
After each successful backup, Safe Harbor scans the output directory for files matching safeharbor-backup-*.tar with the expected timestamped filename shape. Files that do not match that pattern are ignored by retention.
The command sorts matching tarballs by file modification time, newest first. It keeps the set union of:
{BACKUP_RETENTION_DAILY most recent tarballs}
union
{first tarball encountered for each ISO week, up to BACKUP_RETENTION_WEEKLY weeks}
Any matching tarball outside that kept set is deleted.
For example, assume BACKUP_RETENTION_DAILY=7, BACKUP_RETENTION_WEEKLY=4, and 14 daily backups span 2 ISO weeks. The algorithm keeps:
{7 most recent} union {1 per ISO week x 4 weeks}
That means up to 7 daily backups plus up to 4 weekly marker backups are retained, with overlap deduplicated. In the 14-backup, 2-week example, the weekly markers may already be among the 7 most recent files, so the final count can be less than 11.
Weekly markers are based on each tarball's file modification time in UTC, not on a timestamp parsed from inside the archive.
Off-site copy
Keep ./backups/ on durable local storage, then copy or sync that directory to storage outside the Docker host. The backup command only writes local tarballs; off-site transfer is a host responsibility.
SMB (Windows / generic)
Install CIFS support on the host, then create a credentials file readable only by root:
sudo install -m 0600 -o root -g root /dev/null /etc/safeharbor-smb.credentials
Example credentials file:
username=backup-user
password=change-this-password
domain=WORKGROUP
Mount the SMB share:
sudo mkdir -p /mnt/safeharbor-backups
sudo mount.cifs //nas.example.local/safeharbor-backups /mnt/safeharbor-backups \
-o credentials=/etc/safeharbor-smb.credentials,uid=1000,gid=1000,iocharset=utf8,file_mode=0600,dir_mode=0700
Sample /etc/fstab entry:
//nas.example.local/safeharbor-backups /mnt/safeharbor-backups cifs credentials=/etc/safeharbor-smb.credentials,uid=1000,gid=1000,iocharset=utf8,file_mode=0600,dir_mode=0700,nofail,x-systemd.automount 0 0
Point Safe Harbor's ./backups/ directory at the mounted path. One simple option is to replace ./backups/ with a symlink:
mkdir -p /mnt/safeharbor-backups/current
ln -sfn /mnt/safeharbor-backups/current ./backups
You can also keep ./backups/ as a normal local directory and run a host-level sync job from ./backups/ to the mounted share.
NFS (Linux / generic)
Install NFS client support on the host, create a mountpoint, and mount the export with NFSv4:
sudo mkdir -p /mnt/safeharbor-backups
sudo mount.nfs nas.example.local:/exports/safeharbor-backups /mnt/safeharbor-backups \
-o nfsvers=4,rw,hard,noatime
Sample /etc/fstab entry:
nas.example.local:/exports/safeharbor-backups /mnt/safeharbor-backups nfs nfsvers=4,rw,hard,noatime,nofail,x-systemd.automount 0 0
As with SMB, either point ./backups/ at a directory on the mount or keep ./backups/ local and sync it to the mounted path from the host.
Build your own
Tools such as rclone, restic, and borg are good fits for host-managed backup replication. Point them at ./backups/, let Safe Harbor produce the local tarballs, and keep the transfer policy outside the application stack.
Security note: docker socket exposure
Ofelia needs limited Docker socket access
Ofelia mounts /var/run/docker.sock read-only so it can discover labeled containers and run the configured exec job inside the backups container. This is an exec-only scheduling model: the scheduler does not need start or stop privileges for this workflow, but Docker socket access is still sensitive because the worst-case impact is the ability to execute commands inside other containers, no start/stop privilege.
The compose service applies two container-level mitigations:
no-new-privileges:trueprevents the ofelia process from gaining extra privileges inside its container.read_only: truegives the ofelia container a read-only filesystem.- The backups service mounts uploads RW because the restore CLI writes uploads as part of its operation. Defense-in-depth lives at the docker-socket level (read-only mount on ofelia) and at the non-root safeharbor user inside the container, not at the uploads mount.
These mitigations reduce the scheduler container's write surface, but they do not make Docker socket access risk-free. Run the backup profile only on hosts where the Compose operator is trusted.
Disaster recovery
Backups are only useful if they can be restored. See Restore for the disaster recovery procedure, including dry-run validation and the restore command that overwrites the database and uploads from a selected tarball.