Encrypts Coolify backup files in place — same path, same filename — so Coolify's own retention keeps working, while every backup Coolify writes ends up encrypted on disk.
Encryption is asymmetric with age: the host
only ever gets a public key, so it can encrypt but never decrypt.
No warranty. Use at your own risk. Small script, written for people who run Coolify in production and are willing to own their backup and restore process. It is not a one-click product, and it has been tested against exactly one Coolify version (see Schema dependency). Read Restore and verify your own restore before you rely on it.
Coolify decides what to delete purely from its database: retention runs
rm -f <filename> (or delete(<filename) against S3) using the exact path it
recorded when the backup finished.
Rename, move or re-wrap a backup file and Coolify keeps looking at the original path — retention breaks, or files pile up forever.
So this script never changes the path. It rewrites the file in place once Coolify has finished writing it. Coolify itself is not patched, which is what makes this survive Coolify upgrades.
- Every run reads finished backups from Coolify's own PostgreSQL database. The database is only ever read, never written.
- Each referenced file that is not encrypted yet is encrypted to
<file>.tmp.$$and then atomicallymv'd over the original. Owner, group and permission bits are preserved. - Every encrypted file starts with the magic header
COOLIFYENC1, so a file that was already encrypted is skipped. The script is idempotent. - Only files below
BACKUP_ROOTare touched. Anything else fails the run. - Guards run before anything is modified: database container up, recipient actually usable, Coolify schema still matching.
Covered sources:
| Source | Table |
|---|---|
Database backups, including Coolify's own coolify-db self-backup |
scheduled_database_backup_executions |
| Volume backups | scheduled_volume_backup_executions |
- You run Coolify in production with data you cannot lose.
- You want backups encrypted offsite and you do not want to give the backup host the ability to decrypt them.
- You are comfortable with SSH,
systemdandage, and you will actually run a restore drill.
Not for you if you want one-click, if you cannot keep a private key safe, or if you will never test a restore.
- Not "production ready", no warranty, no support. Verify your own restores.
- Not a Coolify plugin or patch — an external script.
- Not covering backups that live on another server. Only files present on the machine running the script are encrypted. See Multiple servers.
- Not encrypting what Coolify already pushed to S3. See S3.
- No CI, no automatic releases.
- Ubuntu 24.04 (other systemd distributions should work).
- Root /
sudoon the Coolify host. - Docker with the Coolify database container running (
coolify-db). age— the installer installs it.systemd.
curl -fsSL https://raw.githubusercontent.com/T-Justin96/coolify-backup-encrypt/main/install.sh | sudo bashIt downloads the rest itself. Options go through the pipe:
# install everything but do not start the timer yet
curl -fsSL <url> | sudo bash -s -- --no-enable
# bring your own public key: nothing is generated on the host, nothing to finalize
curl -fsSL <url> | sudo bash -s -- --recipient age1...
# pin a release instead of following main
curl -fsSL https://raw.githubusercontent.com/T-Justin96/coolify-backup-encrypt/main/install.sh \
| sudo bash -s -- --ref v1.2.2Piping a script into a root shell means trusting it blindly. If that bothers you, download it, read it, then run it:
curl -fsSLO https://raw.githubusercontent.com/T-Justin96/coolify-backup-encrypt/main/install.sh less install.sh sudo bash install.sh --ref v1.2.2
raw.githubusercontent.comcan serve a cached copy for a few minutes after a push. To get exactly the released version, pin it with--ref v1.2.2.
git clone https://github.com/T-Justin96/coolify-backup-encrypt
cd coolify-backup-encrypt
sudo ./install.shRun from a checkout, the installer just uses the files next to it.
- checks root, docker and the Coolify database container
- fetches the script and the systemd units (only when run standalone)
- installs
ageif it is missing - installs the script to
/usr/local/bin/ - creates the config and an age key pair (unless you pass
--recipient) - verifies the key pair with a real encrypt/decrypt roundtrip
- installs the systemd units and runs
--check-schemaand--dry-run - starts the timer and prints what to do next
Installer flags:
| Flag | Effect |
|---|---|
--recipient age1... |
use your own public key; nothing is generated on the host, so there is nothing to finalize |
--keep-key |
keep the recipient already in the config, change nothing about encryption |
--new-key |
deliberately generate a new key pair and update the config (old backups become unreadable) |
--ref <git ref> |
fetch this branch/tag/commit instead of main |
--no-enable |
install everything but do not start the timer |
--no-prompt |
never ask; refuse with instructions instead (for scripts) |
--force |
overwrite the whole existing config |
--help |
usage |
Backups are encrypted to a public key, and that key is pinned in
/etc/coolify-backup-encrypt.conf. Replacing it later would make every backup
encrypted with the old key unreadable, so the installer refuses to guess.
If it finds a config that already pins a recipient but no private key on the host, it asks — with the safe answer as the default:
/etc/coolify-backup-encrypt.conf already encrypts to:
age192yffpp9qt329...dexeaf2s2e52xp
There is no private key on this host. What should happen?
a) keep that key, change nothing about encryption [default]
b) GENERATE A NEW KEY PAIR
every backup encrypted with the key above becomes UNREADABLE
Choose [a/b] (enter = a):
Without a terminal (or with --no-prompt) it does not guess either: it stops
and prints the exact commands. Nothing is written before that decision — the
installer picks the key first and only then touches any file.
The same prompt appears when the private key on the host does not match the recipient in the config, which is the other way this can silently go wrong.
There is nothing to run day to day. The timer encrypts each finished backup within a minute, forever.
| Path | What it is |
|---|---|
/usr/local/bin/coolify-backup-encrypt.sh |
the script; --help lists every option |
/etc/coolify-backup-encrypt.conf |
configuration, mode 600 |
/etc/systemd/system/coolify-backup-encrypt.timer |
the schedule (60 seconds) |
/etc/systemd/system/coolify-backup-encrypt.service |
what the timer starts |
/run/coolify-backup-encrypt.missing |
state file: which backups are not on this host |
/root/GRAB-ME-BEFORE-DELETE-identity.txt |
temporary private key; remove it with --finalize |
If the installer must not create a key pair on the server at all:
-
Offline, on a machine you trust:
age-keygen -o coolify-backup-age.key # the IDENTITY - keep it offline age-keygen -y coolify-backup-age.key # prints age1... - this goes to the server
-
On the Coolify host (put your own
age1...value in):curl -fsSL https://raw.githubusercontent.com/T-Justin96/coolify-backup-encrypt/main/install.sh \ | sudo bash -s -- --recipient age1...
No key pair is generated, so there is nothing to finalize. The host has a public key and nothing else.
Run --status first, always. It answers "what is installed and what is going on":
# coolify-backup-encrypt.sh --status
coolify-backup-encrypt 1.2.0
script /usr/local/bin/coolify-backup-encrypt.sh
config /etc/coolify-backup-encrypt.conf (mode 600)
crypto age, public-key only
recipient age192yffpp9qt329...dexeaf2s2e52xp
key on host no (good)
identity /etc/coolify-backup-encrypt/age/identity.txt (absent - only needed for --decrypt/--verify)
container coolify-db running
coolify version 4.3.19
timer enabled, active
schedule Sat 2026-09-12 12:32:40 CEST 58s Sat 2026-09-12 12:31:40 CEST 1min ago coolify-backup-encrypt.timer coolify-backup-encrypt.service
backups 8 referenced -> 2 encrypted, 0 pending, 6 not on this host| Command | What it does |
|---|---|
--status |
everything above, on one screen |
--dry-run |
what would be encrypted right now; changes nothing |
--check-schema |
preflight + schema guard. Run after every Coolify upgrade |
--verify FILE [--identity K] |
header, format, size — and with a key, a real decrypt |
--decrypt FILE |
decrypt to stdout |
--decrypt-to OUT ENC |
decrypt into OUT (mode 0600, atomic); refuses to overwrite without --force |
--ask-key |
always ask for the private key on the terminal; pasted keys never touch the disk |
--no-prompt |
never ask; fail with instructions instead (scripts cannot hang) |
--update [--ref REF] |
newest script + units. Never touches config or keys |
--uninstall [--purge] |
stop and remove everything; backup files are kept |
--cleanup-tmp |
remove *.tmp.* leftovers of crashed runs |
--finalize |
delete the bootstrapped private key from this host |
--version, --help |
version / usage |
A pass prints nothing when there is nothing to do, so the journal stays readable:
journalctl -u coolify-backup-encrypt.service -n 30
systemctl --failedExit codes: 0 on success (including "nothing to do"), non-zero when an
encryption failed, when a path fell outside BACKUP_ROOT, or when the schema
check failed. That is what makes OnFailure= alert you.
sudo coolify-backup-encrypt.sh --update # follow main
sudo coolify-backup-encrypt.sh --update --ref v1.2.2 # pin a releaseIt downloads the script and the units, checks that the script is valid bash,
prints the version change, installs, reloads systemd, and re-runs
--check-schema. Config, keys and backups are never touched.
Re-running the installer is not the same thing. It refuses to touch an existing config on purpose, because generating a second key pair would hand you a key that does not match your backups.
sudo coolify-backup-encrypt.sh --uninstall # keeps config and /etc/.../
sudo coolify-backup-encrypt.sh --uninstall --purge # removes config tooBackup files are never deleted. They stay encrypted and need your private key.
Read this if you enabled save_s3 in Coolify.
Coolify uploads a backup to S3 as soon as it is created. This script runs afterwards and rewrites only the local file. So:
The S3 object stays plaintext. Encrypting the local copy does not change it.
--status tells you when this applies:
s3 3 backup(s) were uploaded to S3 by Coolify in PLAINTEXT
this script only encrypts the local copy afterwards
Your options:
- Turn
save_s3off and copy the encrypted local files offsite with your own tooling. This keeps the "host cannot decrypt" property. Recommended. - Or accept the plaintext S3 copy and secure the bucket accordingly.
Every backup file is written on the server where the database or volume lives. This script only encrypts what exists on the machine it runs on. Anything else is reported once and then stays quiet:
NOT ON THIS HOST (other server, S3-only, or deleted): /data/coolify/backups/databases/root-team-0/.../pg-dump-....dmp
If --status shows files under not on this host, those backups are not
encrypted by this install. Install the script on those servers too — with the
same public key (--recipient) or a separate key per server.
/run/coolify-backup-encrypt.missing remembers what was already reported, so the
journal does not fill up.
- The Coolify host holds only the public key. It can encrypt, never decrypt.
- The private key never goes on the Coolify host. Keep it offline, and keep a second copy somewhere else.
- Never put the private key into a backup directory, a Git repo, or anything
that gets synced or uploaded — including
/data/coolify/backups/. - Losing the private key means losing every backup. No recovery path.
- Rotating keys cannot make old backups readable with a new key: keep old private keys until the corresponding backups have aged out of your retention.
install.shbootstraps the key pair on the host for convenience, so until you run--finalizethat host can decrypt.--finalizedeletes the file after an explicit confirmation and usesshredwhen available (best-effort only on SSDs and some filesystems).--statusshows whether a key is still on the host. That is the line to check.
Do a drill before you need one. Coolify's restore button and its download links hand you the encrypted file. Coolify cannot decrypt it, so restoring is a manual two-step job.
The private key does not have to exist as a file. Get the script onto the machine that holds the key (any Linux box will do) and run:
scp root@your-coolify:/data/coolify/backups/.../backup.dmp .
coolify-backup-encrypt.sh --decrypt-to restore.dmp backup.dmp
# it asks: paste the private key (the AGE-SECRET-KEY-1... line), then Enter--ask-key forces the prompt even if a key file happens to be present.
What the prompt does and does not do:
- reads from the terminal, so nothing is echoed and nothing lands in your shell history
- keeps the key in memory only — age receives it through a
/dev/fd, not a file - rejects anything that is not a private key, so pasting the public
age1...key by mistake fails right away instead of producing garbage - if you paste the whole identity file, the leftover lines are thrown away instead of being run as shell commands afterwards
By hand, with the header stripped:
tail -c +13 backup.dmp | age --decrypt --identity ./backup-identity.txt > restore.dmphead -c 5 restore.dmp # must print: PGDMP
ls -l restore.dmp # plausible sizePGDMP is the header of a PostgreSQL custom-format dump. If the script is on
that machine, --verify does all of this in one go, including a real decrypt:
coolify-backup-encrypt.sh --verify backup.dmp --identity ./backup-identity.txtDo not install postgresql-client just for this. If you want the stronger
check, use the container that already has the tools:
cat restore.dmp | docker exec -i coolify-db pg_restore --list | head -20- Database: restore the plain dump as usual. Prefer a throwaway container
first. Remember that the
coolify-dbself-backup exists. - Volume: stop the container that uses the volume, unpack into the volume path, start it again.
- Never restore over a running production database without a snapshot.
- Can I decrypt a backup today, on a machine that is not the Coolify host?
- Does
head -c 5showPGDMP? - Did I actually restore it somewhere and query data?
- Is the private key backed up, and will I still find it in five years?
Read this after every Coolify upgrade.
This script reads Coolify's internal tables. They have no public stability contract, so a Coolify upgrade may rename or remove a column at any time.
Required tables and columns (verified against Coolify v4.3.19):
| Table | Columns |
|---|---|
scheduled_database_backup_executions |
status, finished_at, local_storage_deleted, filename, s3_uploaded |
scheduled_volume_backup_executions |
status, finished_at, local_storage_deleted, filename, s3_uploaded |
Value dependency: only rows with status = 'success' are picked up.
Safety net. Before touching a single file the script verifies those columns
against information_schema and exits non-zero, naming the missing columns.
--check-schema does exactly that and nothing else.
coolify-backup-encrypt.sh --check-schema || echo "SCHEMA CHANGED - do not trust your backups"Because the timer runs every minute, a changed schema shows up in the journal
immediately and OnFailure= fires.
If it fails, the fix is usually a one-line change to the query in
fetch_filenames() plus SCHEMA_TABLES / SCHEMA_COLUMNS — and the matching
update in coolify-backup-encrypt.selftest.sh, whose fake docker returns a
fixed column list.
- Coolify v4.3.19 (2026-09) — schema verified against this version.
- Other versions: run
--check-schemafirst. Nothing else is guaranteed.
/etc/coolify-backup-encrypt.conf. Optional, except AGE_RECIPIENT.
| Variable | Default | Meaning |
|---|---|---|
AGE_RECIPIENT |
– | Required. The public key (age1...) backups are encrypted to. |
AGE_IDENTITY |
/etc/coolify-backup-encrypt/age/identity.txt |
Private key path, only used by --decrypt / --verify. |
GRAB_IDENTITY |
/root/GRAB-ME-BEFORE-DELETE-identity.txt |
Where install.sh leaves the bootstrapped key. |
BACKUP_ROOT |
/data/coolify/backups |
Files outside this root are never touched. |
COOLIFY_ENV_FILE |
/data/coolify/source/.env |
Source of DB credentials and Coolify version. |
DB_CONTAINER |
coolify-db |
Database container name. |
GRACE_SECONDS |
5 |
Wait this long after finished_at before encrypting. |
MAX_LOAD |
0 |
>0: skip a run when load1 > MAX_LOAD * cpus. |
LOCK_FILE |
/run/coolify-backup-encrypt.lock |
Prevents concurrent runs. |
STATE_DIR |
/run |
Where the "not on this host" state file is kept. |
TMP_MAX_AGE_MINUTES |
60 |
--cleanup-tmp age threshold. |
MAGIC |
COOLIFYENC1 |
Idempotency header. Never change it once you have encrypted backups. |
To change how often it runs, edit OnUnitActiveSec= in
/etc/systemd/system/coolify-backup-encrypt.timer, then:
systemctl daemon-reload && systemctl restart coolify-backup-encrypt.timer| Symptom | Cause / fix |
|---|---|
Couldn't create temporary file /tmp/apt.conf.XXXX during install |
A problem with your host's /tmp, not with the installer. Check df -h /tmp /, df -i /tmp /, ls -ld /tmp (must be drwxrwxrwt). apt falls back to the old index, so the install usually still succeeds. |
pg_restore: command not found |
You do not need it. head -c 5 restore.dmp must print PGDMP, or use --verify. For the strong check: cat restore.dmp | docker exec -i coolify-db pg_restore --list. |
journalctl shows nothing for the service |
Normal. A pass with nothing to do is silent on purpose. Use --status. |
NOT ON THIS HOST lines |
Those backups live on another server or are S3-only. See Multiple servers. |
--status says key on host YES |
The bootstrap key is still there. Copy it away, verify it, then run --finalize. |
The installer stopped halfway, or says refusing to guess |
Read the message: it names the exact command to continue. --keep-key keeps the recipient your backups are already encrypted to (almost always what you want), --new-key starts over and makes old backups unreadable. Earlier versions could leave the script replaced but the systemd units stale; --update now repairs that (it compares files, not just version numbers). |
bash: --recipient: invalid option |
You piped into bash without -s --. It must be curl ... | sudo bash -s -- --recipient age1..., otherwise bash itself swallows the flag. |
--decrypt asks for a key / no usable private key |
Paste the AGE-SECRET-KEY-1... line, or pass --identity FILE, or run it where the key file lives. In a script, add --no-prompt so it fails instead of waiting. |
| Pasted a key but it says "does not look like an age private key" | You pasted the public key (age1...) or a comment line. It needs the line starting with AGE-SECRET-KEY-1. |
systemctl --failed lists the unit |
You got an alert. Look at journalctl -u coolify-backup-encrypt.service -n 50. |
bash coolify-backup-encrypt.selftest.shNeeds no Coolify, no docker and no root: docker and flock are faked, the
systemd paths are redirected, and an isolated age key pair is generated per case.
It covers dry-run, in-place encryption, idempotency, the decrypt roundtrip,
--decrypt-to, the overwrite guard, the magic header guard, the
schema-changed failure path, --cleanup-tmp, --finalize, --verify, the
non-zero exit on real failures, and the silence of a no-op pass.
If age is missing on the test machine, the crypto cases fall back to a base64
stub and say so:
[stub] age not installed here - plumbing only, no real crypto
That still proves all of the file handling, but not the cryptography itself — so
run the test once on a host that has age before you trust a release.
sudo coolify-backup-encrypt.sh --uninstall # keeps config and /etc/.../
sudo coolify-backup-encrypt.sh --uninstall --purge # removes config tooBackup files are never deleted. They stay encrypted — decrypt them before you delete the private key, or keep the key.
ageprovides authenticated encryption. There is no symmetric fallback and nogpgmode: the host only ever gets a public key, which keeps the "encrypt only" guarantee simple to reason about.- The script never writes to the Coolify database and never modifies Coolify.
- The installer prints your host name in the "copy the private key" hint, so that output ends up in scrollback and journals. Check it before pasting publicly.
- Never paste private keys, identities, or decrypted dumps into a public issue.
Small, focused changes with a test please: bash coolify-backup-encrypt.selftest.sh.
If you touch the SQL, also update the fake docker column list in the self-test
and the Schema dependency section.
MIT — see LICENSE. Provided as is, without warranty of any kind.