Backups and restore
The cloud libraries live in the server’s database. If the machine is lost and you have no copy, what was only there is lost (the apps keep their own local copy, but rebuilding from them is a lot of work). A useful copy does four things: it is outside the machine, it is encrypted, it contains everything that cannot be regenerated and it has been restored once for real.
What to copy
Section titled “What to copy”- The database, with
pg_dump --format=custom(compressed, and restored withpg_restore). server/.env.local, which carriesCLOUD_KEY_SECRET,APP_SECRETand the keys of the services. WithoutCLOUD_KEY_SECRET, the copy of the database does not allow signing for the libraries already created. The script encrypts it the same way as the database.- Your nginx and systemd configuration, if you do not have it written down somewhere else. The code is not needed: it is in its repository.
Encrypt the copies: whoever gets into the machine cannot read them
Section titled “Encrypt the copies: whoever gets into the machine cannot read them”The copies carry the data of all the libraries and the server’s secrets. If someone gets into the machine and the copies are in the clear, they also take the old ones. That is why they are encrypted with age, using a public key that lives on the server and only serves to encrypt; the private key is never on the server, but on your computer (and in another safe place). That way not even yesterday’s copy can be opened from the machine.
age is in the repositories of Debian 12 or later and Ubuntu 22.04 or later: sudo apt install age. Create the key pair on your computer, not on the server:
age-keygen -o kosmotheka-backup-key.txt# Public key: age1...- Keep
kosmotheka-backup-key.txt(the private one) outside the server’s machine, with a copy somewhere else. Without it, the copies cannot be opened: it is one more copy to protect, and losing it means losing the copies. - On the server put only the
age1...line (the public one):
sudo mkdir -p /etc/kosmothekaecho 'age1...your-public-key...' | sudo tee /etc/kosmotheka/backup-recipient.txtWith gpg instead of age
Section titled “With gpg instead of age”If you would rather not install anything, gpg does the same and already comes with almost every Linux, on the server and on your computer. Create the key pair on your computer; the email is only a label and does not need to exist:
gpg --quick-generate-key "Kosmotheka backups <backups@example.org>" future-default default nevergpg --armor --export backups@example.org > backups-public.ascgpg --armor --export-secret-keys backups@example.org > backups-private.asc # keep it elsewhere and delete it hereOn the server, import only the public one (sudo gpg --import backups-public.asc) and note its fingerprint, the 40-character line of sudo gpg --list-keys. In the script, replace each age -R "$RECIPIENT" with:
gpg --homedir /root/.gnupg --batch --yes --trust-model always --no-auto-key-locate --encrypt --recipient THE_FINGERPRINT- Use the fingerprint, not the email: with an email that is not in the keyring, gpg tries to look the key up on the internet and the backup fails.
--homedirsetsroot’s keyring even when systemd starts the service withoutHOME.- Change the
.ageextension of the names to.gpgas well. The check at the end of the script changes: a gpg file does not start withage-encryption.org/v1. Remove it, or check withgpg --list-packetsfrom your computer. - To open a backup:
gpg -d db-YYYY-MM-DD.dump.gpg, instead ofage -d -i ….
The script
Section titled “The script”Save it as /usr/local/sbin/kosmotheka-backup, with chmod 750. It runs as the administrator (root), asks postgres for the dump, encrypts it through a pipe (the plain dump never touches the disk) and keeps seven daily copies and four weekly ones.
#!/usr/bin/env bashset -euo pipefailumask 077
DB=kosmothekaENV_FILE=/srv/kosmotheka/server/.env.localRECIPIENT=/etc/kosmotheka/backup-recipient.txtDEST=/var/backups/kosmothekaSTAMP=$(date +%F)
mkdir -p "$DEST/daily" "$DEST/weekly"
# The dump is encrypted on the fly and written under another name, which is changed when it finishes:# an incomplete copy never looks complete. With pipefail, if pg_dump fails, everything fails.runuser -u postgres -- pg_dump --format=custom "$DB" \ | age -R "$RECIPIENT" > "$DEST/daily/db-$STAMP.dump.age.partial"mv "$DEST/daily/db-$STAMP.dump.age.partial" "$DEST/daily/db-$STAMP.dump.age"
age -R "$RECIPIENT" < "$ENV_FILE" > "$DEST/daily/env-$STAMP.local.age"
# The server cannot decrypt, so it only checks that what was written is an age file.for f in "$DEST/daily/db-$STAMP.dump.age" "$DEST/daily/env-$STAMP.local.age"; do [ "$(head -c 21 "$f")" = "age-encryption.org/v1" ] || { echo "damaged copy: $f" >&2; exit 1; }done
# On Sundays, also a weekly copy.if [ "$(date +%u)" = 7 ]; then cp "$DEST/daily/db-$STAMP.dump.age" "$DEST/daily/env-$STAMP.local.age" "$DEST/weekly/"fi
# Rotation: 7 days and 4 weeks.find "$DEST/daily" -type f -mtime +7 -deletefind "$DEST/weekly" -type f -mtime +28 -delete
echo "kosmotheka-backup: copy $STAMP made"If you use another database or path, change the first lines. Check that you can open a copy (see “Restore”) before you trust it: it is the only thing the server cannot do for you.
Make it automatic: a systemd timer
Section titled “Make it automatic: a systemd timer”/etc/systemd/system/kosmotheka-backup.service:
[Unit]Description=Kosmotheka backup
[Service]Type=oneshotExecStart=/usr/local/sbin/kosmotheka-backup/etc/systemd/system/kosmotheka-backup.timer:
[Unit]Description=Daily Kosmotheka backup
[Timer]OnCalendar=*-*-* 03:30:00RandomizedDelaySec=15mPersistent=true
[Install]WantedBy=timers.targetsudo systemctl daemon-reloadsudo systemctl enable --now kosmotheka-backup.timersudo systemctl start kosmotheka-backup.service # a test nowls -l /var/backups/kosmotheka/daily(With cron it works the same: one line in /etc/cron.d that runs the script every day.)
Take them off the machine
Section titled “Take them off the machine”A copy on the same disk does not protect you from losing the disk. Choose a place that is not that machine: another computer of yours, an external disk, another provider’s storage. An example with rsync from another computer, which pulls the copies (so the server’s machine keeps no credential to access your copy):
rsync -a --delete user@your-domain:/var/backups/kosmotheka/ ~/copies/kosmotheka/Run it from a timer on that other computer. Since the copies are already encrypted with age, they can travel and be kept anywhere without whoever holds them being able to read them.
Restore
Section titled “Restore”The copies are opened with the private key, which is on your computer. Decrypt and restore through a pipe, so the plain dump does not touch the disk either. Practise the restore before you need it, on a test database, not on the real one. With the copy db-YYYY-MM-DD.dump.age and your kosmotheka-backup-key.txt:
sudo -u postgres createdb -O kosmotheka kosmotheka_testage -d -i kosmotheka-backup-key.txt db-YYYY-MM-DD.dump.age \ | sudo -u postgres pg_restore --no-owner --role=kosmotheka --dbname=kosmotheka_testsudo -u postgres psql -d kosmotheka_test -c '\dt'sudo -u postgres psql -d kosmotheka_test -c 'SELECT count(*) FROM cloud_library;'sudo -u postgres dropdb kosmotheka_testTo see your .env.local from the copy: age -d -i kosmotheka-backup-key.txt env-YYYY-MM-DD.local.age. If you restore on another computer that is not the server, just change sudo -u postgres for whatever applies there. It is good if pg_restore gives no errors, the tables appear and the count makes sense.
To recover the server on a new machine:
- Install PHP, PostgreSQL, nginx and the code as in Setting it up without Docker, up to and including the database step, and without running the migrations.
- Restore the copy into the empty database:
age -d -i kosmotheka-backup-key.txt db-YYYY-MM-DD.dump.age | sudo -u postgres pg_restore --no-owner --role=kosmotheka --dbname=kosmotheka. - Decrypt your
.env.localfrom the copy and put it inserver/(with the sameCLOUD_KEY_SECRET). - Continue from nginx and HTTPS. If the server’s name changes, the apps will no longer recognise it:
DEFAULT_URIis part of what each device signs to sign in. Keep the same name. - Run
php8.5 bin/console doctrine:migrations:migrate --no-interaction: it will only apply the ones that are missing, if the copy came from an earlier version of the code.
To confirm: the restore script for a whole server (step by step on a new machine) is written but has not been tested from start to finish; the encrypted backup script, its decryption and the
pg_restoreof the database have been tested with PostgreSQL 18 and age 1.2.1 in a throwaway container.
© 2026 Kosmotheka
Kosmotheka is free software: the app and the server are under the AGPL-3.0-or-later, and the operation spec is under the MIT licence. The name and the logo are not covered by the licence.
kosmotheka.com · Trademark policy · Privacy policy · info@kosmotheka.com