Encrypted Offsite Backups: Hetzner Storage Box with Gocryptfs

Hetzner Storage Boxes provide low-cost offsite backup storage with native snapshot capabilities. This guide covers mounting a Storage Box via CIFS, layering client-side encryption with Gocryptfs, making the encrypted mount persist correctly at boot, and integrating automated rsync pipelines with weekly retention logic backed by Hetzner’s snapshot system.

Prerequisites

The following items are required:

  • Linux server with root access
  • Hetzner Storage Box credentials and hostname
  • cifs-utils package installed
  • gocryptfs package installed
  • SSH access configured between backup source and storage host

NOTE: The storagebox is available to the entire of Hetzner’s networks. If your intention is to backup or use this storage from outside of Hetzner’s networks, you need to enable that, specifically “External Reachability”.

It is a good idea to have the Storage Box set up in a location other than the data it backs up. For example, if all the servers are based in Germany, get the Storage Box set up in Finland or another distant location to avoid problems with regional internet disruptions or state-wide outages.

Backup Retention Strategy

Hetzner Storage Boxes include automatic daily snapshots with a 10-day rolling retention window. By organizing backup directories using ISO week numbers, the retention model provides two recovery tiers without requiring complex rotation scripts or additional storage overhead:

Daily snapshots provide granular recovery for the last 10 days. Any individual day within this window can be restored from Hetzner’s snapshot interface.

Weekly directories organized by ISO week number create a 52-week archive.

Combined, this structure allows recovery of any single day from the last 10 days and any week from the last year while storing only current active files on disk.

The resulting directory structure on the Storage Box follows this pattern:

/mnt/backups/
└── 30/                       <-- ISO Week Number
    └── server42/
        ├── home/
        ├── root/
        ├── etc/
        ├── opt/
        └── dumps/            <-- Database exports

Mounting the Storage Box with CIFS

Create the mount point directories on the backup storage host. Use /mnt/backups_raw for the raw CIFS share and /mnt/backups for the gocryptfs-decrypted view on top of it:

mkdir -p /mnt/backups_raw
mkdir -p /mnt/backups

Create a credentials file at /etc/storagebox.creds:

username=u632495
password=YOUR_HETZNER_STORAGEBOX_PASSWORD

Secure the credentials file:

chmod 600 /etc/storagebox.creds

Add the CIFS mount to /etc/fstab:

//u632495.your-storagebox.de/backup /mnt/backups_raw cifs credentials=/etc/storagebox.creds,iocharset=utf8,rw,uid=0,gid=0,file_mode=0600,dir_mode=0700,nobrl 0 0

Mount the share:

mount /mnt/backups_raw

Configuring Client-Side Encryption with Gocryptfs

Gocryptfs provides FUSE-based transparent filesystem encryption. All encryption and decryption happens on the client before data traverses the network, so the storage provider never sees plaintext.

Install gocryptfs:

apt-get install gocryptfs

Initialize the encrypted filesystem on the raw CIFS mount:

gocryptfs -init /mnt/backups_raw

Save the generated master key and passphrase in an offline password manager. This key is required to decrypt the volume from any other host, and gocryptfs cannot recover it if it is lost.

Mount it by hand to confirm it works:

gocryptfs /mnt/backups_raw /mnt/backups

Data written to /mnt/backups is now transparently encrypted before being stored in /mnt/backups_raw and transmitted over CIFS. Unmount it again before continuing, since the next section replaces this manual step with something that survives a reboot:

fusermount -u /mnt/backups

Making Gocryptfs Mount Automatically at Boot

A manually-run gocryptfs command does not survive a reboot, and a backup job that fires before the mount exists will happily write plaintext straight into an empty, unencrypted directory instead of failing loudly. Both problems are solved by adding gocryptfs to /etc/fstab alongside the CIFS mount, with an explicit ordering dependency between the two.

Create a password file so gocryptfs does not need to prompt interactively at boot:

mkdir -p /etc/gocryptfs
nano /etc/gocryptfs/backups.pass  # paste the gocryptfs password, save
chmod 600 /etc/gocryptfs/backups.pass

Find the actual gocryptfs binary path, since the fstab entry needs it explicitly rather than just the name:

which gocryptfs

Add the entry to /etc/fstab, directly below the CIFS line:

//u632495.your-storagebox.de/backup /mnt/backups_raw cifs credentials=/etc/storagebox.creds,iocharset=utf8,rw,uid=0,gid=0,file_mode=0600,dir_mode=0700,nobrl 0 0
/mnt/backups_raw /mnt/backups fuse./usr/bin/gocryptfs nofail,passfile=/etc/gocryptfs/backups.pass,x-systemd.requires-mounts-for=/mnt/backups_raw 0 0

A few notes on what each piece is doing:

  • The filesystem-type field is fuse. followed by the full path to the gocryptfs binary (from which gocryptfs above). This is how gocryptfs itself documents fstab integration, not just fuse.gocryptfs.
  • passfile= points at the password file created above, letting the mount happen non-interactively.
  • x-systemd.requires-mounts-for=/mnt/backups_raw is the actual fix for mount ordering. Since /etc/fstab entries are converted into systemd mount units, listing them in file order does not guarantee anything about the order they come up in. This option explicitly tells systemd that the gocryptfs mount depends on /mnt/backups_raw already being mounted, so the CIFS share is guaranteed to be in place first.
  • nofail means that if the Storage Box is unreachable at boot (network hiccup, credential issue, Hetzner-side maintenance), the server still boots normally instead of hanging or dropping to an emergency shell. You will just be short a mount until it is manually fixed, which is far preferable to an unbootable backup server.

Test the entry without rebooting:

umount /mnt/backups 2>/dev/null
mount -av
mount | grep backups

Then verify the ordering survives an actual reboot, since mount -av alone will not catch systemd dependency-ordering mistakes:

reboot
# after it comes back up:
systemctl status mnt-backups_raw.mount mnt-backups.mount

Both should show as active, with mnt-backups.mount having started after mnt-backups_raw.mount.

If any other user besides root needs to read /mnt/backups, add allow_other to the gocryptfs fstab options and enable it system-wide first:

echo "user_allow_other" >> /etc/fuse.conf

Verifying Encryption from a Secondary Host

To verify zero-trust operation, mount and decrypt the Storage Box from an entirely separate host.

The secondary host requires the gocryptfs.conf file from the root of the encrypted volume and the master passphrase. Transfer the configuration securely:

scp <storagebox>/gocryptfs.conf /tmp/gocryptfs.conf

On the secondary host, mount the raw CIFS share in read-only mode:

mkdir -p /mnt/test_raw /mnt/test_decrypted
mount -t cifs //u632495.your-storagebox.de/backup /mnt/test_raw -o credentials=/etc/storagebox.creds,ro

Inspect the raw directory contents. Filenames should appear as obfuscated ciphertext:

ls -la /mnt/test_raw

Mount and decrypt the volume using gocryptfs:

gocryptfs /mnt/test_raw /mnt/test_decrypted

Verify the decrypted contents match the original backup structure:

ls -la /mnt/test_decrypted

Unmounting the decrypted filesystem immediately revokes access to plaintext data:

fusermount -u /mnt/test_decrypted

Automated Backup Script

The following example script executes on any server. It sends messages via discord, performs database dumps, syncs directories to a local staging area, then transfers the payload to the encrypted remote Storage Box mount using rsync over SSH.

Create /opt/backup/backup.sh:

#!/bin/bash
set -euo pipefail

/opt/discord/discord.sh "$(hostname -s)" "/opt/backup/backup.sh is starting..."

# --- CONFIGURATION ---
TARGET_HOST="server.domain.com"
TARGET_USER="root"
TARGET_BASE="/mnt/backups"
EXCLUDE_FILE="/opt/backup/backup_exclude.txt"
DB_DUMPS="/eph3/dumps"

# Directories to back up
FOLDERS=(
    "/home"
    "/root"
    "/etc"
    "/opt"
    "/eph3/nextcloud"
    "$DB_DUMPS"
)

# --- DYNAMIC DESTINATION ---
WEEK=$(date +%V)
HOST=$(hostname -s)
DEST_PARENT="${TARGET_BASE}/${WEEK}/${HOST}"

# --- DATABASE DUMPS ---
/opt/discord/discord.sh "$(hostname -s)" "Generating GitLab database archive..."
docker exec -t gitlab gitlab-backup create SKIP=uploads,builds,lfs,packages,terraform_state
mkdir -p "$DB_DUMPS/gitlab"
mv /opt/docker/gitlab/var/opt/gitlab/backups/*_gitlab_backup.tar "$DB_DUMPS/gitlab"
/opt/discord/discord.sh "$(hostname -s)" "GitLab dump complete."

echo "Preparing remote backup folder on ${TARGET_HOST}..."
ssh "${TARGET_USER}@${TARGET_HOST}" "mkdir -p ${DEST_PARENT}"

# --- RSYNC TO ENCRYPTED STORAGE ---
for src in "${FOLDERS[@]}"; do
    folder_name=$(basename "$src")

    /opt/discord/discord.sh "$(hostname -s)" "/opt/backup/backup.sh is syncing $src..."

    if [ -f "$EXCLUDE_FILE" ]; then
        rsync -avzx --delete --exclude-from="$EXCLUDE_FILE" "${src}/" "${TARGET_USER}@${TARGET_HOST}:${DEST_PARENT}/${folder_name}/"
    else
        rsync -avzx --delete "${src}/" "${TARGET_USER}@${TARGET_HOST}:${DEST_PARENT}/${folder_name}/"
    fi
done

/opt/discord/discord.sh "$(hostname -s)" "/opt/backup/backup.sh finished successfully!"

Make the script executable:

chmod +x /opt/backup/backup.sh

The script can be run directly or scheduled via crontab to any required frequency. Since the script created week number based file system its optimal to have it run daily.

Global Exclusion Rules

Create /opt/backup/backup_exclude.txt to filter temporary files, sockets, and cache directories:

# --- Temp and Cache Files ---
*.tmp
*.temp
*.swp
*.bak
.DS_Store
.cache/
cache/
Cache/

# --- System and Runtime Sockets ---
*.sock
*.pid
/proc/
/sys/
/dev/
/run/

# --- Docker and Development ---
node_modules/
.venv/
venv/
docker/btrfs/
docker/overlay2/

# --- Application Logs ---
/logs/
log/
*.log
jellyfin/cache/
jellyfin/config/data/data/

Verification

Execute a manual dry run to verify script operation:

bash -x /opt/backup/backup.sh

Check Discord notifications for execution status messages. Verify the backup payload was written to the encrypted mount on the storage host:

ls -la /mnt/backups/$(date +%V)/server42/

Inspect file timestamps and directory sizes to confirm rsync completed successfully. Test snapshot recovery through the Hetzner Storage Box web interface by browsing the .zfs/snapshot directory structure.

AI assistance is used on this site for language, formatting, and turning research into a consistent template. It is not used to perform the underlying research or verify technical claims. Every command, configuration, and step in this post is tested by hand before publication.

Leave a Reply

Your email address will not be published. Required fields are marked *

5 × 2 =