Installing Postulo
The short version
git clone https://source.tiagoagueda.com/postulo/postulo.git
cd postulo
cp .env.example .env # set POSTULO_SECRET_KEY and POSTULO_ALLOWED_HOSTS
docker compose -f docker/compose.yml up -d
docker compose -f docker/compose.yml exec postulo python manage.py fetch_esco --revision 1.2.1
docker compose -f docker/compose.yml exec postulo python manage.py createsuperuser
The last line is optional: on an empty instance the sign-up form is offered to whoever
opens the site, and the first account created becomes the administrator. Either way that
first account's address counts as verified, so it can sign in before email delivery is
configured; everyone invited afterwards receives a verification link. createsuperuser
asks for a username, an email address, your first and last name, and a password.
Then put a reverse proxy in front of port 8000 to terminate TLS, and name the network it
arrives from in POSTULO_TRUSTED_PROXIES — Server settings → Overview shows the address,
and Hardening says why. That is the whole installation; the rest of this page
is detail and the alternative without a container.
What has been tested. The image has been built and run on a Raspberry Pi (arm64, Debian): it builds, migrates, passes its health check, serves pages, and renders a PDF with WeasyPrint. The Compose file above was followed exactly as written. What has not happened is somebody running it for months of a real job search, so treat it as working rather than as proven.
scripts/check-image.shrepeats that whole check wherever you have Docker.
What you need
- Python 3.12, 3.13 or 3.14
- uv to install dependencies
- git
- Pango, if you want PDF export and are installing without a container. On Debian
or Ubuntu:
sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0— the last one because WeasyPrint warns without it and a later version will require it. The image already has all three. Postulo works without them; you simply cannot export PDFs.
Node is not required. The stylesheet is compiled and committed; Node is only needed if you want to change the CSS.
With Docker
The image carries everything Postulo needs, including Pango, so PDF export works out of the box.
SQLite, which is the right choice for a personal instance — one file to back up, and a job search does not produce the kind of load that needs more:
docker compose -f docker/compose.yml up -d
PostgreSQL, if you already run one and would rather have a single backup regime.
Set POSTGRES_PASSWORD in .env first:
docker compose -f docker/compose.postgres.yml up -d
Both bind to 127.0.0.1:8000 rather than to every interface, on the assumption that a
reverse proxy sits in front. Migrations run automatically on start, so upgrading is
pulling a new image and restarting.
Your data lives in the postulo-data volume: the database (on SQLite) and every
uploaded and generated file. That is what to back up — see
Backups and your data.
To check the image builds and runs before trusting it with anything:
./scripts/check-image.sh
It builds, starts a container, and waits for the health check to answer.
Do not point your reverse proxy at the data volume. Uploaded CVs are delivered only through a view that has established who is asking, and serving the directory would bypass that entirely.
The job title classification
The title box offers the ESCO unit groups, and a title that is recognised carries its ISCO-08 code (#266). The classification is a few megabytes and is not in the image: it is downloaded into the container in place, once.
docker compose -f docker/compose.yml exec postulo python manage.py fetch_esco --revision 1.2.1
The file lives in the container rather than on the data volume, so if you pull a new image and the container comes back, run the line again. Without it Postulo runs on: a title simply matches no code and the title box offers nothing.
Plugins
A plugin is a Python package that adds a capture source, a way of being notified, a
document store or a synchronisation. The usual way to install one is from the
interface, under Server settings → Plugins: upload the package, read what it says
about itself, and install it. Plugins live in /app/data/plugins — on the data volume,
not in the image — so an upgrade cannot lose them; the container reinstalls anything the
volume's record lists and the new image lacks, before the first request.
Installing a plugin runs somebody else's code inside Postulo, with everything Postulo can do. Only administrators can do it, nothing is ever updated automatically, and a plugin can be switched off without being removed.
If you would rather have an immutable image with its plugins baked in, that works too.
A build argument, with any number of packages in the form pip accepts — a name, a
wheel URL, a git+https://… address:
docker compose -f docker/compose.yml build \
--build-arg POSTULO_EXTRA_PACKAGES="git+https://source.tiagoagueda.com/postulo/postulo-apprise.git"
docker compose -f docker/compose.yml up -d
Or, to keep it in the Compose file rather than on the command line:
build:
context: ..
dockerfile: docker/Dockerfile
args:
POSTULO_EXTRA_PACKAGES: git+https://source.tiagoagueda.com/postulo/postulo-apprise.git
Your own image on top of Postulo's, for anyone who would rather not build from source:
FROM source.tiagoagueda.com/postulo/postulo:0.2
USER root
RUN uv pip install --no-cache git+https://source.tiagoagueda.com/postulo/postulo-apprise.git
USER postulo
Either way the plugin is installed with Postulo's own lock as a constraint, so it cannot change the version of anything Postulo pins, and it survives upgrades because it is part of the image you build. Restart, and the plugin appears under Settings → Connections.
Catalogues. A catalogue is a signed list of plugins that can then be installed by
name. Set POSTULO_PLUGIN_CATALOGUES to name|url|public-key entries, separated by
commas; the index must be signed with that key, and every package must match the checksum
the signed index carries, or nothing is installed. Nothing is fetched until an
administrator presses Check for updates. No catalogue is configured by default.
The first one worth adding is
postulo-apprise, which sends
Postulo's notifications to Telegram, ntfy, Discord, Matrix, Gotify and over a hundred
other services.
Trying it on your own machine
git clone https://source.tiagoagueda.com/postulo/postulo.git
cd postulo
uv sync
cp .env.example .env
uv run manage.py migrate
uv run manage.py fetch_esco --revision 1.2.1
uv run manage.py createsuperuser
uv run manage.py runserver
Then open http://127.0.0.1:8000.
A development secret key is generated on first run and kept in data/.dev-secret-key, so
your session survives a restart. runserver is Django's development server: convenient,
single-threaded, and not for anything reachable from the internet.
Putting it on a server
-
Clone and install, as above, but without the development extras:
uv sync --no-dev -
Write a
.env. At minimum:POSTULO_SECRET_KEY=<a long random string> POSTULO_DEBUG=false POSTULO_ALLOWED_HOSTS=postulo.example.org POSTULO_CSRF_TRUSTED_ORIGINS=https://postulo.example.org POSTULO_TIME_ZONE=Europe/ParisGenerate a key with:
python -c "import secrets; print(secrets.token_urlsafe(64))"Every setting is listed in Configuration.
-
Prepare the database and static files, using the production settings:
export DJANGO_SETTINGS_MODULE=postulo.config.settings.prod uv run manage.py migrate uv run manage.py fetch_esco --revision 1.2.1 uv run manage.py collectstatic --noinput uv run manage.py createsuperuser -
Check your configuration. This is worth doing before you expose anything:
uv run manage.py check --deploy -
Run it with a real server. Postulo is a standard WSGI application at
postulo.config.wsgi:application. For example, with gunicorn:uv run gunicorn postulo.config.wsgi:application --bind 127.0.0.1:8000 --workers 3(gunicorn is not a dependency of Postulo; install whichever server you prefer.)
-
Put a reverse proxy in front of it that terminates TLS and forwards to that port. Postulo serves its own static files through WhiteNoise, so the proxy only needs to pass requests through. It must not serve
MEDIA_ROOT— see Files and what you sent for why.
There is no background worker to run. Nothing in Postulo currently queues work.
PDF rendering
WeasyPrint is the default renderer and is installed with Postulo. It produces smaller, more faithful documents than a browser does, and needs no browser to launch.
What it does need is Pango and its companion libraries, which are one package manager command away on Linux:
sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 # Debian and Ubuntu
sudo dnf install pango # Fedora
sudo apk add pango # Alpine
Those libraries are a genuine nuisance on Windows, so a fallback exists — headless Chromium, driven by Playwright:
uv sync --extra chromium
uv run playwright install chromium
Postulo uses whichever actually works, preferring WeasyPrint. "Actually works" means it tries to import the renderer rather than merely checking that the package is present: WeasyPrint installs perfectly happily on a machine with no Pango and only fails when asked to render, so presence is not evidence of anything.
To pin the choice, set POSTULO_PDF_BACKEND to weasyprint or chromium. Export is
optional throughout: tracking applications and writing letters need no renderer at all.
Fonts and the scripts Postulo offers
Documents are drawn with the fonts the machine has. The image carries
fonts-dejavu-core and fonts-noto-core, which cover every script the languages
Postulo offers — Latin, Greek, Cyrillic, Arabic and Ethiopic — and the Indic scripts
that the Asian phase will bring. CJK is deliberately not in the default image:
fonts-noto-cjk is a separate package of a different order of size, and carrying it
by default would double the image for a language almost none of the installations will
use (#74).
If your CV is in Chinese, Japanese or Korean, build the image with the door the Dockerfile leaves open:
docker compose build --build-arg POSTULO_EXTRA_APT_PACKAGES=fonts-noto-cjk
— or, without a container, sudo apt install fonts-noto-cjk beside Pango.
Wherever Postulo runs, ask it what it can draw before a document is sent:
python manage.py check_fonts
The command resolves, for each script the offered languages need, the font the
renderer would pick for it, and checks the character against that font's own table —
the question a render would have asked, asked now. Server settings → Overview shows
the same answer, and manage.py check_fonts exits non-zero where a script would come
out boxes, so a deploy can hold on it. The interface itself draws on the reader's own
system fonts, which is a decision rather than an accident: the boxes it might show are
on the account holder's own screen, and the exposure — a CV that reaches a recruiter —
is the one the check covers (#74).
Upgrading
Back up first — see Backups and your data. Postulo is young enough that an upgrade is worth being able to undo.
With Docker:
git pull
docker compose -f docker/compose.yml up -d --build
Migrations run on start, so there is no separate step to remember.
A rebuild comes back as a new container, and the classification file lives in the
container rather than on the data volume: run the fetch_esco line from
The job title classification again.
Without:
git pull
uv sync
uv run manage.py migrate
uv run manage.py collectstatic --noinput
# restart your server
Releases and upgrading
A release is a tag vX.Y.Z on the repository. Pushing one makes Forgejo build the sdist and
the wheel, create a release with that version's changelog section as its notes and the two
files attached, and — on an instance whose runner can build images — publish the container
image to Forgejo's registry as X.Y.Z, X.Y and latest. From 0.4.0 the release also
carries the image's bill of materials, postulo-X.Y.Z-image-sbom.cdx.json in CycloneDX
form, beside the sdist and the wheel: run your own scanner over it later, against a
vulnerability database that did not exist when the image was built.
The Compose files name the published image pinned to a minor, …/postulo:0.2, so
upgrading is:
docker compose -f docker/compose.yml pull
docker compose -f docker/compose.yml up -d
Building from source still works and always will: docker compose -f docker/compose.yml up -d --build builds the same image name from the checkout. Migrations run when the
container starts, whichever way it was made.
Which version you are running is in the page footer, in full on Server settings →
Overview, and in /healthz, so monitoring can see an upgrade happen. Whether it is the
newest one, Postulo does not know unless you let it ask: set POSTULO_UPDATE_CHECK=true
and the scheduler asks the project's release address once a day, and the Overview says
when a release is out. Off by default, because Postulo makes no request on your behalf
unless you say so; manage.py check_for_updates asks once, by hand. The other way is to
watch the releases page or its feed yourself.
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