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.

oops.png

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.

🖖
Thanks for reading. If you found this helpful, consider following me on bluesky@nishtahir.com.

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.


  1. 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). ↩︎

  2. (2021) Systemd timers onCalendar (cron) format explained. silentlad.com. Available at: https://silentlad.com/systemd-timers-oncalendar-(cron)-format-explained (Accessed: 2025-5-3). ↩︎

  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). ↩︎

Subscribe to Another Dev's Two Cents

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe