Backups for server operators
ECTLogger backs itself up. Admins set the passphrase, schedule, and off-site targets in the admin panel (Backups). This page is the server side: where the files go, what runs the schedule, and how to get an instance back from a backup, which only happens at a shell.
What a backup is
Each backup is one file, ectlogger-YYYYMMDD-HHMMSS.tar.gz.age, in backend/backups/. Inside, once decrypted, is a gzipped tar holding:
| Path in the backup | What it is |
|---|---|
manifest.json |
When it was made, the git commit and latest migration number of the code that made it, and a SHA-256 checksum for every file |
database/ectlogger.db |
A consistent snapshot of the SQLite database, taken with SQLite’s own backup call while the service runs (PostgreSQL: database/database.sql from pg_dump) |
data/ |
Uploaded files: profile photos, chat images, the instance logo, net and schedule logos |
config/backend.env, config/frontend.env |
The two .env files, including SECRET_KEY |
SECRET_KEY matters more than it looks. Every admin’s two-factor secret is encrypted with a key derived from it, so a database restored next to a different SECRET_KEY locks every admin out of two-factor sign-in.
The file is encrypted with age to a public key. The matching private key is stored only inside ectlogger-backup-key-<id>.age, which is locked with the admin’s passphrase and kept beside the backups. The server never stores the passphrase, so a scheduled backup needs no passphrase, and someone who takes over the server cannot open the backups already sent off-site.
What runs the schedule
The admin panel owns the schedule. Something outside it has to check, every few minutes, whether a backup is due. BACKUP_SCHEDULER in backend/.env says what:
| Value | What checks |
|---|---|
cron (default) |
A cron entry for the service account runs backup.py run-if-due every 15 minutes. Backups keep running while the web service is down. |
internal |
The web service checks every 15 minutes. For containers with no cron. |
off |
Nothing. For installs already backed up by snapshotting the whole server, volume, or container. Only Back up now makes a backup. |
install.sh asks whether to set up backups, and if you say yes it sets the passphrase, turns on a daily 03:00 backup in the server’s time zone, and adds the cron entry. On an existing install, add the entry yourself as the account the service runs as:
backend/venv/bin/python backend/scripts/backup.py install-cron
Whatever the scheduler, the web service itself emails every admin once when no backup has succeeded for one and a half schedule periods. A broken or missing cron entry therefore still gets noticed.
BACKUP_DIR in backend/.env moves the backup folder, for example onto a second disk. It is deliberately not settable from the admin panel.
The command line
Every command runs from anywhere, as the service account:
backend/venv/bin/python backend/scripts/backup.py <command>
| Command | What it does |
|---|---|
status |
Settings, the backup folder, when the scheduler last checked in, and the last ten backups |
run |
Makes a backup now, same as Back up now |
run-if-due |
Makes one only if the schedule says so (what cron runs) |
set-passphrase |
Creates the key, or changes its passphrase; --replace makes a new key |
enable, disable |
Turns the schedule on or off; enable --daily 03:00 --timezone America/New_York or enable --every-hours 6 |
verify FILE |
Decrypts a backup, checks every file against its checksum and the database for damage, confirms the backup’s SECRET_KEY opens every two-factor secret in it, and counts what it holds |
restore FILE |
Puts a backup back in place (below) |
install-cron, remove-cron |
Adds or removes the scheduler entry in this account’s crontab |
A passphrase is read from the terminal, or from standard input when there is no terminal.
Restore a backup
This replaces the database, the uploaded files, and (optionally) the configuration.
- Stop the service:
sudo systemctl stop ectlogger. - Put the backup file where the server can read it. The key file is found automatically when it sits in the same folder; otherwise add
--key path/to/ectlogger-backup-key-<id>.age. -
Run the restore and enter the passphrase:
backend/venv/bin/python backend/scripts/backup.py restore ectlogger-20260928-030000.tar.gz.age - Read what it prints. It lists everything it put in place. The files it replaced are kept beside the new ones with a
.pre-restore-<time>suffix, so a restore can be undone by moving them back. - If it says the backup predates a migration, run the listed scripts from
backend/migrations/in order. - Start the service:
sudo systemctl start ectlogger.
The restore checks every file against the manifest first and changes nothing if any check fails. If the backup’s SECRET_KEY cannot open the two-factor secrets in its own database, it says so and carries on: the data is intact, but those accounts will need their two-factor reset. It refuses to run while the service’s port answers, and refuses a backup made by newer code than the checkout it is running from (check out the commit it names first).
Existing .env files are not overwritten. The backup’s copy is written beside yours as backend/.env.from-backup to compare. Pass --with-env to replace them instead. Every .env file the restore writes is readable only by the account that ran it, since it holds SECRET_KEY and the mail password.
Move an instance to a new server
- On the old server, make a fresh backup: Back up now, or
backup.py run. - On the new server, clone the repository and run
./install.sh. Answer no when it offers to configure the application; the backup brings the configuration with it. - Copy the backup and its key file across, then run
backup.py restoreas above. With no.envon the new server yet, the backup’s copies are put in place. - If the address is changing, run
./migrate.sh --host new.example.orgto update the URLs in both.envfiles. - Build the frontend (
cd frontend && npm run build), install the service with./install-service.sh, and start it. - Sign in as an admin and open Backups. The passphrase, schedule, and off-site targets came across with the database. Run
backup.py install-cronto put the scheduler back.
SFTP targets keep the host key they trusted, and the upload key they were given still works, because both are in the database.
Open a backup without ECTLogger
If all you have is a backup file, its key file, and the passphrase, the stock age tool opens it on any machine:
age -d -i ectlogger-backup-key-<id>.age ectlogger-20260928-030000.tar.gz.age | tar xz
age asks for the passphrase to unlock the key file, then decrypts the backup. The database is in database/ectlogger.db and opens with any SQLite tool.
A safe SFTP drop point
An off-site target that the application server can log into is also one an intruder on that server can log into. Set it up so that is worth nothing to them:
-
A dedicated account that can only use SFTP, locked into one folder. It needs no shell, and no other folder on that machine should be visible to it. A small SFTP-only container does this well, for example
atmoz/sftpwith only the upload folder mounted:services: ectlogger-sftp: image: atmoz/sftp command: ectlogger::1099 ports: - "2222:22" volumes: - ./ectlogger.pub:/home/ectlogger/.ssh/keys/ectlogger.pub:ro - ./ssh_host_ed25519_key:/etc/ssh/ssh_host_ed25519_key:ro - /srv/backups/ectlogger/inbox:/home/ectlogger/upload restart: unless-stoppedectlogger.pubis the public key the admin panel shows for the target, and the upload folder must be writable by the account’s user id (sudo chown 1099 /srv/backups/ectlogger/inbox). Pick an id that no account on the storage machine uses (getent passwd 1099prints nothing): the first ordinary accounts are usually 1000 and 1001, and an upload account sharing one would make that person the owner of every backup. Generate the host key once withssh-keygen -t ed25519 -N "" -f ssh_host_ed25519_keyand keep it: a container that invents a new one each time it is recreated looks exactly like the server being impersonated, and backups stop until an admin trusts the new key. The target’s folder is thenupload. The image only reads the key when it creates the account, so after adding or changingectlogger.pub, recreate the container (docker compose up -d --force-recreate); a plain restart keeps the old key. - A firewall rule that lets only the application server reach that port, and nothing else on the network.
-
Move uploads out of reach. Run a job on the storage machine that moves finished uploads out of the upload folder into one the SFTP account cannot see, and prunes there instead:
#!/bin/sh # Every hour: take finished uploads out of the SFTP account's reach. inbox=/srv/backups/ectlogger/inbox archive=/srv/backups/ectlogger/archive mkdir -p "$archive" for f in "$inbox"/*.age; do [ -f "$f" ] || continue dest="$archive/$(basename "$f")" if [ ! -e "$dest" ]; then mv "$f" "$dest" && chown root:root "$dest" elif cmp -s "$f" "$dest"; then rm -f "$f" # the same key file, uploaded again else echo "Left in inbox, differs from the archived copy: $f" fi done # Keep six months of backups; key files are never deleted. find "$archive" -name 'ectlogger-2*.tar.gz.age' -mtime +180 -deleteIn-progress uploads end in
.partialand are left alone. The script never overwrites a file already in the archive: an intruder on the application server could otherwise upload a file with the same name as an old backup and replace it. Leave Delete old backups here off for this target: the app can only see the upload folder, and the archive is what holds the history.
With all three, the most an intruder on the application server can do to the drop point is upload files into an empty folder. They cannot read, change, or delete the backups already made, and every one of those is encrypted anyway.
Limits
- PostgreSQL needs
pg_dumpinstalled on the application server. The restore writes the dump asbackend/database-<time>.sqland prints thepsqlcommand to load it; load it into an empty database. - MySQL is not supported by the built-in backups. Back it up with its own tools and set
BACKUP_SCHEDULER=off. - While a backup is being made or checked, the backup folder briefly holds an unencrypted copy of the database and uploaded files, in a hidden
.staging-or.verify-folder, as well as the backup itself, so leave room for both. The copy is deleted when the backup or check finishes, even if it fails. A restore unpacks into a hidden.restore-folder inbackend/in the same way, and deletes it when it finishes. If any of these is killed outright (out of memory, a reboot), the leftover folder is deleted once it is an hour old: by the next scheduler check or backup, or by the next restore.
Next
Backups covers the admin panel side. Production deployment has the rest of the operational checklist.