Writing a backup service for Ubuntu in Rust
While performing some routine maintenance of my Ubuntu server,
$ docker compose pull
$ docker compose up -d
I ran into an interesting situation where nginx-proxy-manager
failed to serve my blog properly, I wasn't sure what the issue was but these types of issues are usually solved with a restart.
$ docker compose restart
Under normal circumstances this would be unremarkable, however, my GoToSocial
which runs on the same server was in the middle of database migration I had unknowingly interruped.
The documentation mentions that migrations can take a long time. Poking through the logs some of the migrations happening included changes to
timestamp="03/05/2025 15:20:45.881" func=migrations.init.101.func1 level=INFO msg="migrating applications to new model, this may take a bit of time, please wait and do not interrupt!"
timestamp="03/05/2025 15:20:45.893" func=migrations.init.101.func1.1 level=INFO msg="preparing to migrate application GoToSocial Settings (01KGGWWYK4NDHF3WCNS3P3KM5G) to new model..."
timestamp="03/05/2025 15:20:46.282" func=bun.(*InsertQuery).Model level=INFO msg="prepared 1 of 72 new model applications"
...
timestamp="03/05/2025 15:21:03.003" func=migrations.init.108.func1 level=INFO msg="converting accounts to new model; this may take a while, please don't interrupt!"
timestamp="03/05/2025 15:21:03.463" func=migrations.init.108.func1.1 level=INFO msg="migrated 100 of 168391 accounts (next page will be from REDACTED)"
timestamp="03/05/2025 15:21:03.572" func=migrations.init.108.func1.1 level=INFO msg="migrated 200 of 168391 accounts (next page will be from REDACTED)"
timestamp="03/05/2025 15:21:03.642" func=migrations.init.108.func1.1 level=INFO msg="migrated 300 of 168391 accounts (next page will be from REDACTED)"
timestamp="03/05/2025 15:21:03.728" func=migrations.init.108.func1.1 level=INFO msg="migrated 400 of 168391 accounts (next page will be from REDACTED)"
Unfortunately interupting the process left me with
error creating instance application: CreateInstanceApplication: db error getting instance account: sqlite3: SQL logic error: no such column: account.memorialized_at (code=1 extended=1)
I couldn't find a way to recover this however I do keep VPS snapshot backups which I was able to use to recover an older snapshot of the DB.
The process I came up with was mounting a block storage volume to an instance of the backup and copying the db over and mounting the volume onto the main server and retrieving the copy of the DB.
This seemed like a somewhat low effort way of making a portable backup of the DB so I thought it would be interesting to jerry-rig
a low effort backup solution and learn a bit about systemd
timers[1] in the process.
The Systemd service
The systemd
service is pretty straight forward, we can declare a one-shot service type which will be executed on some interval.
# /lib/systemd/system/simple-backupd.service
[Unit]
Description=Run simple-backupd one-shot backup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/simple-backupd
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
This will be invoked by a timer managed by systemd
. The interval is described by OnCalendar
which accepts a cron
format string[2].
# /lib/systemd/system/simple-backupd.timer
[Timer]
OnCalendar=*-*-* 06:15:00
Persistent=true
[Install]
WantedBy=timers.target
The timer is visible by reloading the systemd daemon and querying for timers declared by the service
$ systemctl daemon-reload
$ systemctl list-timers simple-backupd
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2025-05-04 06:15:00 UTC 9h left Sat 2025-05-03 19:16:55 UTC 1h 0min ago simple-backupd.timer simple-backupd.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.
Now I have a timer that will attempt to invoke a binary simple-backupd
, which I opted to write in Rust
as a simple CLI app invoking rsync
on a configured source and destination folder
fn run_backup(cfg: &Config) -> Result<()> {
let stamp = Local::now().format("%F").to_string(); // YYYY-MM-DD
let dest_day = cfg.dest.join(&stamp);
fs::create_dir_all(&dest_day).with_context(|| format!("mkdir {}", dest_day.display()))?;
log::info!(
"simple-backupd: {} → {}",
cfg.src.display(),
dest_day.display()
);
let status = Command::new("rsync")
.args(&cfg.rsync_opts)
.arg(format!("{}/", cfg.src.display()))
.arg(format!("{}/", dest_day.display()))
.stdout(Stdio::null())
.status()
.context("spawn rsync")?;
if !status.success() {
bail!("rsync exit {}", status);
}
log::info!("simple-backupd: OK");
if cfg.max_backups > 0 {
prune_backups(&cfg.dest, cfg.max_backups)?;
}
Ok(())
}
This gets us a static timer which has to be configured by updating the *.timer
file declared by the service. One mechanism for making this dynamic is to define an override file[3].
# /etc/systemd/system/simple-backupd.timer.d/override.conf
[Timer]
OnCalendar=*-*-* 06:15:00
This was easy to implement by adding functionality to the program.
fn write_override(schedule: &str) -> Result<()> {
fs::create_dir_all(OVERRIDE_DIR)?;
fs::write(OVERRIDE_FILE, format!("[Timer]\nOnCalendar={schedule}\n"))?;
println!("✓ Wrote timer override → {OVERRIDE_FILE}");
Ok(())
}
and ensure the timer is enabled by reloading the daemon and enabling the timer.
fn ensure_timer_enabled() -> Result<()> {
Command::new("systemctl").arg("daemon-reload").status()?;
let ok = Command::new("systemctl")
.args(["enable", "--now", "simple-backupd.timer"])
.status()?
.success();
if !ok {
bail!("failed to enable simple-backupd.timer");
}
println!("✔ simple-backupd.timer enabled & started");
Ok(())
}
systemd
takes care of merging the configs which can be validated by using systemd cat
$ systemctl cat simple-backupd.timer
# /lib/systemd/system/simple-backupd.timer
[Timer]
OnCalendar=*-*-* 06:15:00
Persistent=true
[Install]
WantedBy=timers.target
# /etc/systemd/system/simple-backupd.timer.d/override.conf
[Timer]
OnCalendar=*-*-* 06:15:00
Conclusion
I packaged it up as a small deb
utility using cargo deb
and have it running on a twice a day schedule. There are much better ways to do this with offsite and incremental backup solutions but this works well for my usecase. The code is available here if you'd like to play with and/or extend it.
Islam, R. (2024) Configuring and implementing systemd timers - rockibul Islam. medium.com. Available at: https://medium.com/@rockibul.islam20/configure-and-implement-of-systemd-timers-8c640cc6d667 (Accessed: 2025-5-3). ↩︎
(2021) Systemd timers onCalendar (cron) format explained. silentlad.com. Available at: https://silentlad.com/systemd-timers-oncalendar-(cron)-format-explained (Accessed: 2025-5-3). ↩︎
(no date) unix.stackexchange.com. Available at: https://unix.stackexchange.com/questions/670089/is-it-possible-to-dynamically-update-systemd-timers-oncalendar (Accessed: 2025-5-3). ↩︎