Skip to content

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 (including those about no application), interviews, tags, CVs, cover letters, uploads, the files each application went out with and every document you sent (with where it went), the people you recorded at no company, and the places remembered from your corrections on the sites you capture from.

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. Opening the page or asking for an archive removes expired ones (at most once an hour), so this does not wait for the scheduler. 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.

Only yourself, without the job search. Your details and your career record can be taken out on their own, as one readable JSON file, and put back — here or on another Postulo. Your career record has the detail.

One kind of record as a file

Settings → Your data → One kind of record as a file has a card for each of Companies, Contacts, Listings and Applications, with how many of each you hold. Each is a readable JSON file of its own, to keep, to read, or to put into another account, and each page reads one back.

  • A company with its industries, departments and identifiers. A contact with the company it is at, by name, and every way of reaching them. A listing with its company by name. An application with its listing, its company, its whole timeline and its interviews.
  • What is left out, and each file says so in its note: logos and pictures, uploaded files, reminders, offers, what the plugins hold, and the people at a company (they are the contacts file). A JSON file cannot carry a file; the archive above is where those go.
  • Reading one is a review, not an action. You are shown what the file holds, what would be added, what you already have, and what cannot be added and why. Nothing is written until you press Add what is new.
  • It adds and never changes. A record is the same as one you hold when it says the same thing: a company by name or by an identifier it carries; a contact by email, else by name at the company; a listing by its address, else its company, title and date; an application by its listing and the day it was sent. What you already hold is left exactly as it is, and the same file twice adds nothing the second time. A company or listing a contact or application needs and you do not have is added with it, and the page says so.
  • Every imported application carries an Imported from file.json entry on its timeline, as the spreadsheet import writes one.
  • Bounded, because the file is a stranger's: 2 MB and 5,000 records. Only the keys Postulo writes are read, and every value goes through the same checks as the form it would have been typed into. A telephone number another account on this instance already holds is left off, as the form would, and is not said to be.
  • The whole-account archive does not read these files and they do not read it: each says which kind of file it is, and a file handed to the wrong page is refused with a sentence saying so.

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.

A file Postulo wrote itself — Your record as a file — is read on a page of its own, which shows what it found and what it would do, adds what is new, and leaves what you already have exactly as it was. Importing the same file twice adds nothing the second time. 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:

  1. Upload the file, or download the template with Postulo's own columns first.
  2. 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.
  3. 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. A status is matched as whole words, so Rejected after interview is rejected: an outcome beats a stage.
  4. Import, as an errand you can watch. A sheet of up to 5,000 rows is read in chunks of 200, each in a transaction of its own, so the instance is never held up and a sheet that stops half way keeps the chunks before it. Imported history is not announced to your notifiers. A status the spreadsheet states decides what a row becomes: draft is a listing, any other status an application; a row with no status becomes an application if it has a date applied and a listing if it has none. Applications are dated as the spreadsheet says. A listing keeps its deadline (so the closing-soon notice watches it) and its tags are written into its description; a deadline that cannot be read is kept in the notes. 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.

Import checks every file it unpacks. Anything larger than the upload limit for its kind (5 MB for the profile picture and company logos, 20 MB for documents) is left out and listed in the report, and the profile picture and company logos are re-encoded or sanitised as an upload would be.

An archive written by an older Postulo is read as it was written. Its language codes are respelt on the way in: Postulo writes pt-BR where an older one wrote pt-br, and the Serbian an older archive calls sr is restored as sr-Cyrl, which is what the list calls it now. Something an archive calls a language that is not one is left blank, and the command says so. An archive from before reminders about no application, the files an application went out with, or the link from a settled interview to its entry was written restores without them.

Each record is built from the fields Postulo writes for it and from nothing else: an id, a key ending in _id or the name of a file field in an edited archive is not read, and the report says what was left out.

The page that reads Your record as a file does not read an archive, and says so if it is handed one: an archive is every model, the files beside it and an id to remap for each, and it is put back with the command above.

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.

What a CV had chosen to print comes with it. An archive names a chosen number, link, address or identifier by what it says, not by an id — an identifier of the scheme Other by what you called it as well, so two that share a value are told apart — and the import finds it again among the rows it has just made for you. One it cannot find — a telephone number another account on this instance already holds, an email address that is not one of this account's confirmed ones — goes back to following your details, and the import's report says so. So does an answer in an edited file that cannot be read at all; it never stops the import. An archive made before this could be chosen imports with every CV printing what it always printed.

An identifier of a kind this instance does not have comes back as Other. An archive is one person's, and the kinds of identifier an instance defined for itself are the instance's: they travel in its backup, not in anybody's archive. So an archive from an instance with a Staff number kind, restored on one without it — or on one that gave the same key to something else — brings the identifier back as Other, named by the kind's key (staff-number), with its value as it was. So does one whose value the kind here does not accept: the same key with another pattern on the two instances, or a pattern changed since the archive was written. Nothing is dropped and nothing is stored under a kind it does not fit. That goes for a person's identifiers and for a company's, a CV that had chosen to print it still prints it, and the import's report names each one and says which of the two it was. Restored where the kind is defined and takes the value, it is restored as that kind, written the kind's own way. The archive's format is unchanged.

It runs in one transaction: an archive that turns out to be broken half way through leaves the account exactly as it was.

The username in the archive is taken only when it is free and passes the same rules as signing up — the pattern and the list of reserved names. Otherwise the account keeps the name it has and the rest of the archive is restored; where the name broke the rules, the command says so on a skipped: line. So an archive from an account that createsuperuser named admin restores into any account, which keeps its own name. The one refusal is an account with no username at all (one made before usernames existed) asked to take a name the rules refuse: change account.username in postulo.json, or remove it.

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.

Messaging handles come back with their services, and a service this instance does not offer comes back as Other, named by its key, so none is lost. An archive that predates them restores none, and one that predates the choice of how a listing's description is read restores every description as plain text.

Postal addresses come back exactly as they were written, including one its country would now refuse on Your details: that one is marked Kept as it was there until you change it. An archive is never refused for an address.

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.

The database is where an instance's own settings are, so they come with it: what Server settings holds, and with it the kinds of identifier the instance defined for itself (Configuration). A restore puts that text back as it was and Postulo reads it by the rules the page applies: a kind the page would refuse today is left out, the log names it, and so does the page the next time an administrator opens it.

--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 encrypts only what it sends to a backup destination itself (Off-site copies below).

manage.py backup into POSTULO_BACKUP_DIR queues the archive for the backup destination, when one is chosen; --no-send leaves it here. An archive written anywhere else is never sent.

From Server settings

Server settings → Backups does everything above without a shell, for administrators only. Anybody else gets a 404 on every address under it.

  • The list. Every archive in POSTULO_BACKUP_DIR, newest first: when it was written, its size, the Postulo version and database engine, the record counts from its manifest, and whether it verifies. Verifying reads the whole archive, so it is done when you press Check and remembered for the file as it is: a page load never hashes anything, and an archive that changed since is Not checked yet again. One that fails says Does not verify in words. The page also shows the free space on the volume and warns when the next backup is unlikely to fit, judged by the newest archive's size.
  • Back up now. Runs as a background task so the request is never held, and the page shows Taking a backup… in a live region and polls until it is done. Only one backup runs at a time, whoever asks: a second press, or the scheduler on the same minute, finds out that one is already running. With no worker configured it runs where the request stands, and the page waits.
  • Download. The archive holds everyone's data, unencrypted, so downloading needs you to sign in again (the same recent-authentication step as Delete my account), is written to the log with your name and the file's, and is streamed as an attachment. Only files named the way Postulo names them, directly inside the backup directory, can be reached, and never a symlink. Postulo does not encrypt the download: it travels over the connection you reach the instance by, and what you do with it afterwards is yours to protect, with restic, borg or age as above.
  • Delete. A confirmation page naming the archive, the same re-authentication, and a log line. The only archive known to verify cannot be deleted: check another one first, or take a new backup.
  • Schedule and retention. Off, daily or weekly, at an hour in the instance's time zone, keeping the newest N (seven by default). Old backups are deleted only after the new one has been written and verified, and an archive you uploaded is never deleted by retention. The scheduler runs it (queued to the worker when there is one, so a pass never waits on a long backup), so it needs the scheduler going round; the page shows the next run, the last scheduled run and how it went, and Overview says so when one failed or was never picked up. The cron line above stays valid for anyone who prefers it.
  • Off-site copies. Choose the destination opens a page of its own: a backup destination plugin, an age recipient (age1…), how many to keep there, and the plugin's own settings. Every archive written into the directory — by the schedule, Back up now or manage.py backup — is then encrypted to that recipient and sent by the scheduler, in the worker when there is one. Each archive on the list says Sent (when, and its name there), Waiting, or Failed in the plugin's words; a failure is retried with a growing wait, after six attempts it waits for you to press Send, and Overview says so as it does for a failed backup. An archive that has not arrived there is never deleted here by retention. Retention there runs only after a send has arrived, keeps the newest N (as many as here when left blank; 0 deletes nothing there, for a credential that may not delete), and only ever deletes an archive Postulo sent itself.
  • Restoring. Restoring stays on the command line, because it replaces the database under running workers. Choose Restore beside an archive, or upload one (up to POSTULO_BACKUP_UPLOAD_MAX_MB, 512 by default, checked before any of it is kept): the page verifies it, shows what it holds, and writes out the stop, restore and start commands for Compose and for a bare install with the archive's path filled in.

The two numbers an alert wants, postulo_backup_last_success_timestamp_seconds and postulo_backup_failures_total, are on Health, metrics and logs, with the two for off-site copies beside them.

Restoring

Backups are signed: an archive carries an integrity.json whose code is made with a key derived from the instance's secret key, and a restore refuses an archive that does not check out, because the plugins directory in it is code that runs at every start. An archive from another instance, or one taken before signing, restores with --unverified, which skips the check and says so. An archive with no signature at all is treated the same way, because one whose signature was removed by whoever altered it looks exactly like one written before signing. The Restore page shows which of the two it is looking at and prints --unverified in the command when it is needed.

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, the scheduler or the background worker 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

A database that was never migrated (POSTULO_SKIP_MIGRATE=1, or a new host) counts as empty. On PostgreSQL a restore is all or nothing: Postulo writes the dump out as SQL and runs it with psql in a single transaction that stops at the first error. If anything fails, you get a message that says nothing was restored, and the database is exactly as it was before. A restore replaces the contents of the public schema, including any tables an earlier version created that the backup does not hold; it keeps the schema itself, so extensions and grants stay. This needs psql as well as pg_restore; the official image ships both in postgresql-client-17. 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.

An archive written by a newer Postulo than the one restoring it is refused, naming the version to install; so is a database that carries migrations this code does not know. Pass --allow-newer to go ahead anyway, which is a different decision from --force.

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, the scheduler and the background worker live, which is exactly what must not happen. Stop them, whatever Compose profile they sit in, 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 --profile scheduler --profile worker stop
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 --profile scheduler --profile worker up -d

Name only the profiles you run: the worker one exists with POSTULO_BACKGROUND_WORK=true. Setting COMPOSE_PROFILES in .env makes a plain stop and up -d cover them.

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.

An archive from the backup destination

What a destination holds is encrypted with age to the recipient on Choose the destination, and nothing on the instance can open it: only the public key is kept there. Make the key pair on a machine of your own, keep the identity (the AGE-SECRET-KEY-1… line) somewhere that is neither the instance nor the destination, and keep a second copy of it. Without it, no archive that was sent can be opened.

age-keygen -o postulo-backups.key     # prints the public key: paste that, age1…, into Postulo

Postulo takes an X25519 recipient only, the kind age-keygen writes; an SSH key or a plugin's recipient is refused. To restore from an archive you fetched from the destination, decrypt it first and then restore it as any other:

age --decrypt -i postulo-backups.key \
  -o postulo-backup-20260101-030000.tar.gz postulo-backup-20260101-030000.tar.gz.age
uv run manage.py restore postulo-backup-20260101-030000.tar.gz --force

rage, the Rust implementation, takes the same arguments. The decrypted archive is the one that was signed, so it restores without --unverified on the instance that wrote it.

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 what is now unreadable: the connections whose secrets are encrypted under the old key, and the mail and text settings on Server settings (the SMTP password, the mail consent, the transports' secrets). Set POSTULO_FIELD_KEY to the old key and restore again, or enter each password or token afresh: on each connection, and on Server settings → Email for the SMTP password or the mail consent.
  • 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_media lists 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:

  1. The database.
  2. SQLite (the default): data/postulo.sqlite3
  3. PostgreSQL: a pg_dump of your database
  4. The media directory, data/media by 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. Profile pictures and company logos are not here: they are rows of the database, so the database backup carries them and --no-media leaves them in. A database upgraded from before they moved keeps the old files here until the release after, so a rollback finds them; prune_media then lists them as orphans for you to remove.
  5. The plugins directory, data/plugins by default. plugins.json says 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

  1. Install Postulo at the same version the backup came from, and stop it — nothing may have the database open while you replace it.
  2. Put the database file back, or pg_restore the dump.
  3. Delete the WAL sidecars. SQLite runs in WAL mode, which keeps postulo.sqlite3-wal and postulo.sqlite3-shm beside 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-shm

Copy the three together and you have the same problem in reverse — a -wal from one moment beside a database file from another. This is why manage.py backup uses SQLite's own backup API instead, and why copying the file while Postulo runs is never safe. 4. Unpack the media directory and the plugins directory into place. 5. Put .env back, or set POSTULO_FIELD_KEY to the key the instance had, or the stored connection credentials cannot be read. 6. 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.