VPS Snaps

How to encrypt backups on Linux with GPG and age

Encrypt each backup on the server, before upload, to a public key whose private half never touches the server: tar -czf - /var/www | gpg --encrypt --recipient KEY > www.tar.gz.gpg with GPG, or age -r age1... with age. A leaked bucket key, or an attacker on the server, then exposes no past backups. Keep the private key in two safe places, because without it nobody can decrypt the backups, you included.

8 min readUpdated Tested on Ubuntu 24.04 LTS, GnuPG 2.4.4

Why encrypt when the bucket is private

A private bucket is only as private as every key that can read it: the one on the server, the one in a CI job, the one a former colleague still has. Backups hold the most sensitive files you have, such as .env and wp-config.php secrets and database dumps full of customer data and password hashes. Encrypted before upload, the bucket holds only ciphertext, whoever reads it.

Public key or passphrase?

Public keyPassphrase (symmetric)
On the serverThe public key onlyThe passphrase
Server can decrypt its own backupsNoYes
Attacker with root on the serverReads no past backupReads every backup
Restore happensWhere the private key isAnywhere with the passphrase

A server that cannot decrypt its own backups is the point. Use a key pair unless you have a reason not to.

GPG: create the key pair on your own machine

Make the key on your workstation, not the server. GnuPG asks for a passphrase to protect the private key:

Terminal
gpg --quick-generate-key 'Server Backups <[email protected]>' default default never

default default creates a signing primary key and an encryption subkey; never means it does not expire. GnuPG also stores a revocation certificate in ~/.gnupg/openpgp-revocs.d/. List the key to get its fingerprint:

Terminal
gpg --list-keys --keyid-format long [email protected]
Output
pub   ed25519/6A1D1AD200953CD5 2026-10-03 [SC]
      A1D07C3D7D97CB70E2285FFD6A1D1AD200953CD5
uid                 [ultimate] Server Backups <[email protected]>
sub   cv25519/2A11A2E49A408442 2026-10-03 [E]

The 40-character line is the fingerprint; use yours wherever this guide shows A1D07C3D…. Export the public key and copy backup-public.asc to the server:

Terminal
gpg --armor --export --output backup-public.asc [email protected]

GPG: import the public key on the server

Backups run as root, so run the server-side commands in a root shell (sudo -i); the key then lands in root's keyring.

Terminal
gpg --import backup-public.asc

An imported key is not trusted yet, and in batch mode gpg refuses it: There is no assurance this key belongs to the named user, then encryption failed: Unusable public key. You copied the key yourself, so mark it trusted once:

Terminal
echo 'A1D07C3D7D97CB70E2285FFD6A1D1AD200953CD5:6:' | gpg --import-ownertrust

It prints gpg: inserting ownertrust of 6 (ultimate). Adding --trust-model always to each encrypt command works too.

Encrypt a backup as it is made

Stream tar straight into gpg so no unencrypted archive touches the disk. set -o pipefail makes the pipe fail when tar fails; without it, only gpg's exit status counts:

Terminal
set -o pipefail
Terminal
tar -czf - -C / var/www | gpg --batch --encrypt --recipient A1D07C3D7D97CB70E2285FFD6A1D1AD200953CD5 --output /var/backups/www-2026-10-03.tar.gz.gpg
  • -C / var/www archives /var/www without tar's warning about removing the leading /.
  • --batch never prompts, which scripts need.
  • --recipient takes the fingerprint, so no other key with the same email can be picked by mistake.

The server cannot open what it just made:

Terminal
gpg --decrypt --output /dev/null /var/backups/www-2026-10-03.tar.gz.gpg
Output
gpg: encrypted with cv25519 key, ID 2A11A2E49A408442, created 2026-10-03
      "Server Backups <[email protected]>"
gpg: public key decryption failed: No secret key
gpg: decryption failed: No secret key

To stream straight to storage, rclone rcat uploads standard input to one object: ... | gpg ... | rclone rcat offsite:my-bucket/web1/www.tar.gz.gpg. rclone's docs warn that such an upload cannot be retried and that large transfers are better written locally and then moved, which the script below does. Setting up the offsite remote is in how to back up with rclone.

A script for cron

/usr/local/bin/encrypted-backup.sh
#!/usr/bin/env bash
set -euo pipefail

KEY="A1D07C3D7D97CB70E2285FFD6A1D1AD200953CD5"   # your key's fingerprint
OUTBOX="/var/backups/outbox"
FILE="$OUTBOX/www-$(date +%F_%H%M).tar.gz.gpg"

trap 'rm -f "$FILE.partial"' EXIT

tar -czf - -C / var/www \
  | gpg --batch --encrypt --recipient "$KEY" --output "$FILE.partial"
mv "$FILE.partial" "$FILE"

rclone move "$OUTBOX" offsite:my-bucket/web1 --exclude '*.partial'

The archive gets its final name only after gpg succeeds, and the trap deletes a half-written file. In our test, pointing tar at a missing directory made the script exit 2 and left nothing behind. rclone move uploads the outbox and deletes each local file once it has been copied. Create the outbox first with install -d -m 700 /var/backups/outbox, make the script executable with chmod 700, and schedule it as root as in how to schedule backups with cron.

Symmetric encryption with a passphrase file

If the server must be able to decrypt its own backups, use a passphrase. Keep it in a root-only file, never on the command line, where ps can show it. As root:

Terminal
head -c 32 /dev/urandom | base64 > /root/.backup-passphrase
Terminal
chmod 600 /root/.backup-passphrase
Terminal
tar -czf - -C / var/www | gpg --batch --pinentry-mode loopback --passphrase-file /root/.backup-passphrase --symmetric --cipher-algo AES256 --output /var/backups/www-sym.tar.gz.gpg

The manual says --passphrase-file is used only with --batch, and since GnuPG 2.1 only with --pinentry-mode loopback. It names AES-128 as the default cipher, though 2.4.4 chose AES-256 in our test; --cipher-algo AES256 makes it explicit. Decrypt with the same options and --decrypt:

Terminal
gpg --batch --pinentry-mode loopback --passphrase-file /root/.backup-passphrase --decrypt /var/backups/www-sym.tar.gz.gpg | tar -tzf -

A wrong passphrase ends with gpg: decryption failed: Bad session key and exit code 2.

Decrypt and restore

On the machine with the private key, pipe the decrypted stream into tar. With set -o pipefail set, a failure in either half shows in the exit code:

Terminal
gpg --decrypt www-2026-10-03.tar.gz.gpg | tar -xzf - -C /srv/restore

Run mkdir -p /srv/restore first. Our GnuPG 2.4.4 key produced an AEAD packet (aead encrypted packet in --list-packets), a format GnuPG added in 2.3.0, so decrypt with GnuPG 2.3 or newer; Ubuntu 22.04 ships 2.2.

Verify an encrypted backup

On the server, without the private key, check which key a file is encrypted to. --list-only skips the decryption attempt:

Terminal
gpg --list-only --list-packets /var/backups/www-2026-10-03.tar.gz.gpg
Output
# off=0 ctb=84 tag=1 hlen=2 plen=94
:pubkey enc packet: version 3, algo 18, keyid 2A11A2E49A408442
	data: [263 bits]
	data: [392 bits]
# off=96 ctb=d4 tag=20 hlen=2 plen=0 partial new-ctb
:aead encrypted packet: cipher=9 aead=2 cb=16
	length: unknown

keyid 2A11A2E49A408442 is the encryption subkey from the sub line earlier. The real test is decrypting on the key machine and listing the archive:

Terminal
gpg --decrypt www-2026-10-03.tar.gz.gpg | tar -tzf -
Output
gpg: encrypted with cv25519 key, ID 2A11A2E49A408442, created 2026-10-03
      "Server Backups <[email protected]>"
var/www/
var/www/site/
var/www/site/index.html
var/www/site/.env

Encryption also detects damage. After we changed one byte in a copy, gpg stopped with gpg: gcry_cipher_checktag failed: Checksum error; a copy cut short failed too. Both exited 2. More in how to test a backup restore.

age: the same with smaller keys

age is a small encryption tool with one key type and no trust settings. sudo apt install age works on Ubuntu 22.04 and later (24.04 ships 1.1.1; the current release is 1.3.2). On your workstation, create a key; it prints the public key, which starts with age1:

Terminal
age-keygen -o backup-key.txt

age-keygen -y backup-key.txt prints the public key again later. On the server, as root, encrypt to it with -r:

Terminal
tar -czf - -C / var/www | age -r age1yourpublickey -o /var/backups/www-2026-10-03.tar.gz.age

-R reads recipients from a file, one per line, and every recipient can decrypt. Listing your key and a second one kept offline means losing one key is not fatal:

Terminal
tar -czf - -C / var/www | age -R /etc/backup-recipients.txt -o /var/backups/www-2026-10-03.tar.gz.age

Decrypt on the workstation with the identity file:

Terminal
age -d -i backup-key.txt www-2026-10-03.tar.gz.age | tar -xzf - -C /srv/restore

age -p encrypts with a passphrase, but asks for it at the terminal; there is no flag to read it from a file. age 1.3 adds an age-plugin-batchpass plugin for scripted passphrases, yet its own manual recommends keys for anything a computer runs. To protect the private key file itself with a passphrase, generate it as age-keygen | age -p > backup-key.age; age -d -i backup-key.age then asks for that passphrase. age can also encrypt to an SSH public key: -R ~/.ssh/id_ed25519.pub. Its format is authenticated, so a modified or truncated file fails to decrypt.

Keep the keys

Lose the private key, or its passphrase, and every backup encrypted to it is unreadable. Nothing can recover it.

  • Export the GPG private key: gpg --armor --export-secret-keys --output backup-secret.asc [email protected]. Keep it, its passphrase and the revocation certificate in two places off the server, such as a password manager and an offline copy.
  • Test that copy: import it with gpg --import backup-secret.asc on another machine and decrypt one backup. We did, into an empty keyring, and it worked.
  • When you replace a key, keep the old private key for as long as backups encrypted to it are inside your retention window.

Tools that encrypt for you avoid separate key handling: restic always encrypts, Borg does with repokey, and rclone crypt encrypts files and names on upload. They still need their passwords kept the same way.

Common errors

ErrorFix
There is no assurance this key belongs to the named user / encryption failed: Unusable public keyThe imported key is not trusted. Run the --import-ownertrust step, or add --trust-model always.
decryption failed: No secret keyExpected on the server. Elsewhere, import the private key first.
decryption failed: Bad session keyWrong passphrase, or none was passed.
problem with the agent: Inappropriate ioctl for devicegpg tried to ask for a passphrase with no terminal. In scripts, use --batch --pinentry-mode loopback --passphrase-file.
gcry_cipher_checktag failed: Checksum errorThe file was changed or damaged. Use another copy.

Frequently asked questions

Should I encrypt backups if my S3 bucket is private?
Yes. Privacy depends on every key that can read the bucket staying secret. Encrypting first means a leaked key exposes only ciphertext.
Is GPG or age better for backups?
Both are sound. age has fewer options and simpler keys; GPG is installed almost everywhere and supports passphrase files for scripts.
How do I use gpg without a passphrase prompt in a script?
Encrypt to a public key, which needs no passphrase. For symmetric encryption, add --batch --pinentry-mode loopback --passphrase-file.
Can I recover an encrypted backup without the key?
No. Keep the private key and its passphrase in at least two places away from the server.

How this was checked

The commands were run on Ubuntu 24.04 LTS, GnuPG 2.4.4 on October 3, 2026. Any that need something this test server does not have, such as a second server, a cloud account or another database engine, were checked against the official pages below instead.

Sources, on October 3, 2026: