Automating Self-Hosted Backups with Tar, Age, and Systemd

Table of Contents

Running self-hosted infrastructure requires a reliable backup pipeline that executes silently in the background. Deploying monolithic backup suites often introduces complex state engines, high resource usage, and extra software dependencies. You can build a resilient, zero-knowledge disaster recovery workflow by piping standard Unix archiving tools directly into modern encryption and scheduling them with systemd. This approach ensures your archives remain cryptographically secure before they leave your server, keeping your workflow simple, modular, and easy to inspect.

🔗The Architecture

The pipeline captures the three critical pillars of a self-hosted server: persistent service data (/srv), reverse proxy routing (/etc/caddy/Caddyfile), and container orchestration definitions (/etc/containers/systemd for Podman Quadlets).

The workflow chains four specialized utilities together without writing unencrypted intermediate files to disk:

  • Tar and Zstd: Bundles target directories into a single stream and compresses them with Zstandard for high-speed data reduction and low CPU overhead.
  • Age: Encrypts the compressed stream using X25519 asymmetric cryptography. Encrypting to a public key allows the automated script to run unattended without storing the decryption secret on the server.
  • Filen CLI: Synchronises the encrypted artifact to off-site cloud storage.
  • Systemd: Manages execution scheduling, network dependency checks, and failure logging through the system journal.

🔗Asymmetric Keys vs. The Passphrase Trap

Automating server backups requires a strict separation between encryption and decryption capabilities. A production server responsible for generating backups should never possess the ability to read historical archives. Achieving this security boundary relies on asymmetric public-key cryptography, where encryption and decryption use entirely different keys.

Using symmetric passphrases for automated pipelines introduces significant security risks and operational roadblocks. An interactive passphrase prompt blocks unattended scripts from running. To bypass this prompt, an administrator must store the secret in plaintext inside an environment variable, a script file, or process memory. If an unauthorised actor gains access to the server, that stored passphrase compromises every historical backup archive created with it. Furthermore, passphrases introduce human error into disaster recovery; complex strings are easily forgotten over years of silent automation, rendering backups useless precisely when a restoration is required.

Asymmetric encryption separates these roles cleanly. You generate a key pair on your secure local workstation, deploy only the public key to the server to lock incoming data, and store the private decryption key offline. If the production environment is compromised, an attacker discovers only an unreadable ciphertext and a public key capable of encryption alone.

To create your cryptographic identity, install age on your local workstation and generate the key pair:

# GENERATE IDENTITY
# creates a new key pair on your local workstation with restrictive permissions
age-keygen -o ~/.config/age/identity.txt
chmod 600 ~/.config/age/identity.txt

This generates an identity file containing your private decryption key (AGE-SECRET-KEY-1...). Extract the public key string to embed inside your server's backup script:

# EXTRACT PUBLIC KEY
# displays the public key string safe for server distribution
age-keygen -y ~/.config/age/identity.txt

The resulting string, starting with age1, is safe to distribute to any server, repository, or configuration file.

🔗The Backup Pipeline

The script acts as the coordination layer on the production server. It sequences the archive creation, enforces compression, applies public-key encryption, and guarantees temporary files are cleaned up reliably on exit or hardware failure to prevent storage exhaustion in /tmp.

#!/usr/bin/env bash
set -euo pipefail

# SETUP VARIABLES
# defines timestamp, encryption public key, and temporary archive path
DATE=$(date +%F)
AGE_PUBKEY="age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
TEMP_FILE="/tmp/chitin-${DATE}.tar.zst.age"

# TARGET PATHS
# list of absolute paths containing persistent data and system configurations
BACKUP_SOURCES=(
    "/srv"
    "/etc/caddy/Caddyfile"
    "/etc/containers/systemd"
)

# CLEANUP TRAP
# removes temporary archive on script exit or execution error
cleanup() {
    if [[ -f "$TEMP_FILE" ]]; then
        rm -f "$TEMP_FILE"
    fi
}
trap cleanup ERR EXIT

# ARCHIVE AND ENCRYPT
# streams zstd compressed tarball directly into age public key encryption
tar -c --zstd -f - -P "${BACKUP_SOURCES[@]}" | age -r "$AGE_PUBKEY" -o "$TEMP_FILE"

# CLOUD TRANSFER
# uploads encrypted archive to filen remote storage
/home/dnlmr/.filen-cli/bin/filen upload "$TEMP_FILE" "/chitin/"

# FINAL CLEANUP
# deletes local encrypted archive after successful upload
rm "$TEMP_FILE"

Using the -P (--absolute-names) flag in tar preserves the leading slashes of the source directories. This instructs the archive to retain the exact file system hierarchy required for a bare-metal restoration, mapping container definitions and server configurations directly back to their root locations without requiring manual directory reconstruction.

🔗Deterministic Scheduling with Systemd

Standard cron daemons lack awareness of system state and network availability, frequently executing scripts while network interfaces are offline or during system boot contention. Systemd timer units provide precise execution control, automated failure recovery, and structured logging.

🔗The Service Unit (/etc/systemd/system/backup.service)

[Unit]
Description=Backup script for self-hosted data
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup
User=root
StandardOutput=journal
StandardError=journal

The Wants and After directives explicitly bind the execution of the backup script to the network initialisation state. If the server restarts or experiences network interface dropouts, systemd delays execution until external connectivity is fully restored, preventing failed API calls to the remote storage provider.

🔗The Timer Unit (/etc/systemd/system/backup.timer)

[Unit]
Description=Daily backup timer
Requires=backup.service

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target

The Persistent=true directive guarantees that if the server is powered down during the scheduled execution window, the backup triggers immediately upon the next system boot. Adding RandomizedDelaySec=1h staggers the exact upload time within a one-hour window. This practice prevents bandwidth spikes and avoids triggering rate limits on remote storage APIs when running identical schedules across multiple servers or clusters.

🔗Verifying and Restoring Infrastructure

An unverified backup offers no guarantees during a disaster. To validate archive integrity or recover from a system loss, download the encrypted artifact from remote storage to an administrative machine containing your private key, then process the ciphertext through the decryption pipeline.

# INTEGRITY VERIFICATION
# lists archive contents without writing files to disk
age --decrypt -i ~/.config/age/identity.txt /tmp/chitin-2026-07-16.tar.zst.age | tar -t --zstd -f - -P

# FULL SYSTEM RESTORATION
# extracts absolute paths directly to the root filesystem
age --decrypt -i ~/.config/age/identity.txt /tmp/chitin-2026-07-16.tar.zst.age | sudo tar -x --zstd -f - -P -C /

Combining open-source compression, modern asymmetric encryption, and native Linux process management creates a dependable disaster recovery pipeline. Your server state remains securely encrypted at rest in cloud storage, resilient against infrastructure compromise, and accessible exclusively through the private key you control.