Table of Contents
- Backups and your data
- Taking everything with you
- Bringing a career record in
- Bringing a spreadsheet in
- Putting an archive back
- Backing up the whole instance
- Restoring
- Copying the files by hand
- Backing up SQLite properly
- PostgreSQL
- Restoring by hand
- Getting your data out in a readable form
- What is deliberately not backed up
Backups and your data
Taking everything with you
Settings → Your data. It is also on the dashboard's Shortcuts widget if you have that one turned on, and on the page that deletes your account — leaving without your data should never be the easy path.
One zip, holding a readable JSON document of every record in your account and every file in it: your profile, career record, companies, postings, applications with their whole timeline, reminders, interviews, tags, CVs, cover letters, uploads and every document you sent.
Build it, then download it. Reading every record and every file takes as long as it takes, so the button asks for the archive and the page tells you when it is ready; you can close the tab. The file is then yours to download from Settings → Your data, through a view that checks it is your account and nobody else's — it is never served from the media directory. It is deleted a day later, because it holds everything, and the page says when. Ask for another whenever you like.
From the command line:
uv run manage.py export_data you@example.org --output postulo-backup.zip
The JSON is nested the way the records actually relate — companies contain postings, postings contain applications, applications contain their timeline — rather than being a dump of database tables. The point is that somebody can still do something useful with it in ten years, when Postulo is a memory.
Bringing a career record in
A Europass CV — either the JSON europass.europa.eu exports today or the XML the old CV editor produced — can be read straight into your career record, in two steps, with nothing written until you have seen what was found. Your career record has the detail.
Bringing a spreadsheet in
Most people track a job search in a spreadsheet until it hurts. Settings → Your data → Import from a spreadsheet takes that spreadsheet as a CSV (from Excel, Numbers or Google Sheets: Save as or Download → CSV; commas, semicolons or tabs; any encoding Excel produces) and brings it in without retyping:
- Upload the file, or download the template with Postulo's own columns first.
- Map the columns. Postulo guesses which column is which from the header names — in English, French or Portuguese: Entreprise and Empresa are a company — and every guess can be corrected. Say once whether dates are written day-first or month-first.
- Check the preview of the first rows as they will be read: dates parsed, statuses mapped from whatever the spreadsheet said (entretien is interviewing; a word Postulo does not know becomes applied, with the original kept in a note), and what each row becomes.
- Import, in one transaction. Rows with a date applied become applications, dated as the spreadsheet says; rows without one become listings; companies are matched by name or created; tags are created as needed. Every imported application carries an Imported from file.csv entry on its timeline, so provenance is never in doubt.
A row already recorded — the same posting address, or the same company, role and date applied — is reported and left alone, so importing twice does not double anything. The report says how many rows became what and why any were skipped.
From the command line, for a long history:
uv run manage.py import_csv alex.morgan history.csv --show # print the guessed mapping
uv run manage.py import_csv alex.morgan history.csv --mapping map.json --dry-run
uv run manage.py import_csv alex.morgan history.csv --mapping map.json
Excel's own format is not read; Save as CSV is one click, and it keeps a dependency out of a one-time operation.
Putting an archive back
uv run manage.py import_data you@example.org postulo-backup.zip
Import creates records; it never merges. Deciding whether the "Acme" in a file is the
same Acme already in the database is a judgement Postulo is not in a position to make,
and getting it wrong quietly would be worse than not trying. So an import into an account
that already holds a job search is refused unless you pass --force.
Two exceptions to "never merges", both deliberate:
- Companies are matched by identifier first, then by name. A company in the file that carries a Wikidata item (or any other id) your account already has attaches to that company whatever it is called; otherwise the name decides, the same rule the application form uses, so a forced import attaches to employers you already have rather than creating "Acme" twice.
- A CV whose name is taken gets a number appended. A CV is content rather than an identity, so a clash gets a new name instead of being merged into whatever happened to share its title.
It runs in one transaction: an archive that turns out to be broken half way through leaves the account exactly as it was.
There is no import button in the web interface. Importing is a migration, not an everyday action, and it is worth doing deliberately on a command line rather than by clicking.
Backing up the whole instance
An export is one person's portable copy. A backup is the operator's copy of everything on the instance — every account, the database, the media directory and the plugins — taken consistently while Postulo runs, in one archive:
uv run manage.py backup # into POSTULO_BACKUP_DIR, timestamped
uv run manage.py backup /backups/postulo.tar.gz
In the container, the same command through Compose, writing onto the data volume:
docker compose -f docker/compose.yml exec postulo python manage.py backup
The archive holds a manifest (Postulo version, engine, counts), the database — copied
through SQLite's own backup mechanism, or pg_dump for PostgreSQL, never by copying a
file that is being written to — the media directory, file by file, and the plugins
directory beside it: the record of what is installed and the packages themselves. Every
archive is verified after it is written: the manifest is read back and the database is
checked against its checksum. A backup that was never opened is a hope.
--no-media and --no-plugins leave those out if you have another arrangement for them.
Neither is a good default: a database without the media shows a list of documents that no
longer exist, and a database without the plugins has connections belonging to plugins that
are not there.
The encryption key is not in the archive, and must not be. What the manifest carries is
a one-way mark of it, which is enough for restore to say whether the connection secrets it
has just put back can still be read on this instance — and nothing more. The key itself
lives in .env; see Copying the files by hand below.
Put one on a schedule. A line of cron on the host, daily, with a retention your disk can afford:
15 3 * * * docker compose -f /data/stacks/postulo/docker/compose.yml exec -T postulo python manage.py backup
The archive holds everyone's data on the instance, unencrypted. Keep it where the data itself would be safe, and encrypt it in transit with whatever tool carries it off the machine — restic, borg or age do this well; Postulo does not try to.
Restoring
A restore does not build a new database and swap it in: it overwrites the one that is
there, through SQLite's backup API or pg_restore --clean. So nothing else may be using
it while the command runs. Stop the application first — a web worker or the scheduler
reading through a restore is reading a database that is changing underneath it.
Onto an empty instance — install Postulo, run nothing else — then:
uv run manage.py restore /backups/postulo-backup-20260905-031500.tar.gz
It reads the manifest, refuses an archive from the other database engine, refuses while it
can see anything else connected to the database, puts the database back, writes the media
files and the plugins, and runs migrations so that a backup from an older Postulo lands
correctly on a newer one. An instance that already has accounts is refused unless you pass
--force, which replaces everything on it; media and plugin files that already exist are
kept unless --force is given too.
That refusal is a second pair of eyes, not a lock. On PostgreSQL it sees every connection
to the database; on SQLite it sees the files WAL keeps beside the database for as long as
one is open, which catches the scheduler and a web server that has served anything
recently, and misses one that has been idle long enough to have closed its own. Stopping
the services is what makes a restore safe. --force goes ahead anyway, and is also what
you need if a process was killed and left a stale -shm behind.
In a container
exec runs inside the running web container, with gunicorn and the scheduler live, which
is exactly what must not happen. Stop them and use run instead, which starts a container
of its own on the same volume:
cd /data/stacks/postulo
docker compose -f docker/compose.yml stop postulo scheduler
docker compose -f docker/compose.yml run --rm \
-e POSTULO_SKIP_MIGRATE=1 \
postulo python manage.py restore /app/data/backups/postulo-backup-20260905-031500.tar.gz
docker compose -f docker/compose.yml up -d
POSTULO_SKIP_MIGRATE=1 stops the entry point migrating a database that is about to be
replaced; restore runs the migrations itself, afterwards, on what it put back. Bring the
stack up again when it has finished — the plugins it restored are loaded at the next start,
not by the command.
The archive goes onto the data volume by default, which is where the path above comes from.
Restoring from a file on the host instead means mounting it: add
-v /backups/postulo.tar.gz:/restore.tar.gz:ro to the run line and give the command
/restore.tar.gz.
Afterwards
- Check what the command said about the key. If this instance's key is not the one the
backup was taken with, it says so and counts the connections whose secrets are now
unreadable. Set
POSTULO_FIELD_KEYto the old key and restore again, or open each connection and enter its password or token afresh. - Files the archive did not carry are still there. A restore writes what is in the
archive; it never deletes. On an instance that was not empty, that leaves media belonging
to records that no longer exist.
uv run manage.py prune_medialists files under the media root that nothing points at, and removes them with--remove.
POSTULO_BACKUP_DIR is where backup writes with no target — data/backups by default,
/app/data/backups in the container, beside the data it copies. Move it if that volume
is the thing you are backing up.
Copying the files by hand
If you would rather not use the command, the pieces are plain files:
Three things, and they must be copied together:
- The database.
- SQLite (the default):
data/postulo.sqlite3 - PostgreSQL: a
pg_dumpof your database
- SQLite (the default):
- The media directory,
data/mediaby default. This holds every uploaded file and every PDF snapshot. A database without it will show you a list of documents that no longer exist. - The plugins directory,
data/pluginsby default.plugins.jsonsays what is installed and where each one came from, and the packages sit beside it. A database without it has connections belonging to plugins that are not there.
.env is the third thing, and it is not optional. POSTULO_SECRET_KEY signs sessions,
so losing it logs everyone out — but unless you have set POSTULO_FIELD_KEY, it is also the
key every stored connection credential and the Web Push signing key are derived from. Lose it
and the database still has every connection somebody set up, with a password or a token in it
that nothing can now read: each one has to be entered again, and every browser that allowed
notifications has to allow them again. Configuration
has the detail. Keep .env with the backup — or, better, somewhere the backup is not, since
the archive holds everyone's data and the key is what protects the part of it that is
encrypted.
Backing up SQLite properly
Do not copy the file while the application is running. SQLite has a command that takes a consistent copy safely:
sqlite3 data/postulo.sqlite3 ".backup '/backups/postulo-$(date +%F).sqlite3'"
tar czf /backups/postulo-media-$(date +%F).tar.gz -C data media plugins
.backup writes one consistent file with no -wal beside it, which is the point of using
it: the WAL has been folded in. Or stop Postulo, copy all of it — including the -wal and
-shm files, or none of them — and start it again.
PostgreSQL
pg_dump --format=custom postulo > /backups/postulo-$(date +%F).dump
tar czf /backups/postulo-media-$(date +%F).tar.gz -C data media plugins
Restoring by hand
-
Install Postulo at the same version the backup came from, and stop it — nothing may have the database open while you replace it.
-
Put the database file back, or
pg_restorethe dump. -
Delete the WAL sidecars. SQLite runs in WAL mode, which keeps
postulo.sqlite3-walandpostulo.sqlite3-shmbeside the database file. They belong to the file that was there: leaving them next to a file you have just swapped underneath them means SQLite replays a write-ahead log written against a different database.rm -f data/postulo.sqlite3-wal data/postulo.sqlite3-shmCopy the three together and you have the same problem in reverse — a
-walfrom one moment beside a database file from another. This is whymanage.py backupuses SQLite's own backup API instead, and why copying the file while Postulo runs is never safe. -
Unpack the media directory and the plugins directory into place.
-
Put
.envback, or setPOSTULO_FIELD_KEYto the key the instance had, or the stored connection credentials cannot be read. -
Run
uv run manage.py migrate— it will do nothing if the versions match, which is what you want to see.
Getting your data out in a readable form
Everything is in a plain SQLite file, which is about as portable as data gets: sqlite3,
any database browser, or a few lines of Python will read it. Django can also dump the lot
as JSON:
uv run manage.py dumpdata --natural-foreign --indent 2 > postulo.json
That covers the records but not the files; take the media directory as well.
What is deliberately not backed up
staticfiles/ is generated by collectstatic and can be rebuilt. data/.dev-secret-key
is a development convenience. .venv/ and node_modules/ are dependencies. The SQLite
-wal and -shm files are not in the archive either, and should not be: the database in
it was taken through SQLite's own backup API, with the log already folded in.
And .env is not in the archive, deliberately. It holds the key that protects the
encrypted part of everything else in there, so putting the two in one file would be
keeping the key under the doormat. Back it up — somewhere else.
Postulo
Running it
- Installing Postulo
- Configuration
- Accounts and invitations
- Backups and your data
- Hardening
- Accessibility
- Health, metrics and logs
- Troubleshooting
Using it
- Getting started
- Listings
- Tracking applications
- Capturing postings
- Insights and the dashboard
- Reports
- Your career record
- CVs and portfolios
- Letters
- Files and what you sent
Building on it
Project