Installing Postulo¶
Cette page n'a pas encore été traduite ; elle est donc affichée en anglais.
The short version¶
git clone https://source.tiagoagueda.com/postulo/postulo.git
cd postulo
git checkout vX.Y.Z # the latest release tag, see the releases page
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
This pulls the published image, pinned to the minor of the tag you checked out. To build it
from the checkout instead, add the build override: docker compose -f docker/compose.yml -f
docker/compose.build.yml up -d --build (for PostgreSQL, docker/compose.postgres.yml with
docker/compose.postgres.build.yml). A source build is tagged postulo:local.
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.4 or newer (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:
PostgreSQL, if you already run one and would rather have a single backup regime.
Set POSTGRES_PASSWORD in the .env at the checkout's root first (both containers read it;
any character is allowed in it):
Run every later command with the same -f docker/compose.postgres.yml: the two files share
a project name and a postulo service, so a command naming the other one replaces your web
container with one on an empty database.
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:
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; the skill box offers the ESCO skills as you type, and a skill whose name is one of them carries its identifier (#266). The classification is two files — the occupations, about 6 MB, and the skills in 28 languages, about 4.7 MB — and is not in the image: it is downloaded into the container in place, once, in about half a minute.
The command writes both files or neither. They are written to the data volume
(POSTULO_ESCO_DIR, /app/data/esco in the image), so they survive a pull of a new image
and are shared by the worker and scheduler containers: run the command once, from any
of them.
Without them Postulo runs on: a title matches no code, a skill matches nothing, and neither
box offers anything. For a later revision, run the command with its --revision: it deletes
the replaced revision's two files itself once the new ones are written and validated. In a
hand-filled directory, delete the old files yourself; Postulo refuses to read two of either,
and manage.py check reports it (postulo.E040, postulo.E041).
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. Keep it in a Compose file of your own, beside
docker/compose.yml and not tracked by git, so every rebuild carries it and none can
forget it. docker/compose.plugins.yml:
services:
postulo: &plugins
build:
args:
POSTULO_EXTRA_PACKAGES: git+https://source.tiagoagueda.com/postulo/postulo-apprise.git
scheduler: *plugins
worker: *plugins
docker compose -f docker/compose.yml -f docker/compose.build.yml -f docker/compose.plugins.yml build
docker compose -f docker/compose.yml -f docker/compose.build.yml -f docker/compose.plugins.yml up -d
compose.build.yml gives the image a name of its own, postulo:local, so a later
docker compose pull of the published image can never replace the one you built. An
instance built this way is upgraded by rebuilding with the same three files (see
Upgrading) and never by pulling: a rebuild without the file, or a pull, gives you an image
with no plugin in it.
Your own image on top of Postulo's, for anyone who would rather not build from source:
FROM source.tiagoagueda.com/postulo/postulo:0.3
USER root
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& uv pip install --no-cache --constraint /app/constraints.txt \
git+https://source.tiagoagueda.com/postulo/postulo-apprise.git \
&& rm -rf /var/lib/apt/lists/*
USER postulo
The image carries its pins as /app/constraints.txt, and git is installed here because the
runtime image has none and a git+https requirement needs it. Either way the plugin is
installed with Postulo's own lock as a constraint, so it cannot
change the version of anything Postulo pins. It is part of the image you build, so it
survives an upgrade done by rebuilding that image — FROM the new tag, build, restart —
and not one done by pulling Postulo's. 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 and browser-test groups, and with the server extra (that is where gunicorn comes from):
Add --extra postgres if you use PostgreSQL. --no-dev is not enough: it leaves the
e2e group, so playwright and the test runner would be installed and gunicorn would not.
Every later uv run syncs again unless told otherwise, so run the commands below as
uv run --no-sync …, or repeat the same flags.
- 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/Paris
Generate a key with:
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:
- Run it with a real server. Postulo is a standard WSGI application at
postulo.config.wsgi:application. For example, with gunicorn:
gunicorn is Postulo's own server extra, installed in step 1; any other WSGI server will
do if you prefer one.
-
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. It must pass/manifest.webmanifestand/sw.jsthrough from the root of the site, and be HTTPS, for people to be able to install Postulo as an app (Installing the app). -
Run the scheduler. Reminders, the "quiet" and "closing soon" notices, the expiry of exports and of kept pages, the scheduled backup and the connection syncs are done by one command, and without it none of them happens. Run it as a service next to the server — a systemd unit whose
ExecStartis
(it passes every five minutes; --every 60 changes that), or, if you would rather not
keep a process, a cron line that runs uv run --no-sync manage.py send_due_reminders
every five minutes. The Docker image runs it as the scheduler service.
- Once, for the place search, download the GeoNames cities Postulo looks a town up in:
A background worker is optional. To hand the slow work to one, set
POSTULO_BACKGROUND_WORK=true and run uv run --no-sync manage.py work alongside the
server; see Background work in Configuration. Without it, the slow work
is done inside the request that asked for it.
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:
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, fonts-noto-core and fonts-noto-cjk, which cover every script the
languages Postulo offers or has begun: Latin, Greek, Cyrillic, Arabic, Ethiopic,
Devanagari, Bengali and Han (simplified and traditional Chinese).
fonts-noto-cjk is a separate package of a different order of size. It was left out of
the default image (#74) until Chinese was settled for 0.5.0 (#728), and it is the reason
the image is larger than it was. Japanese and Korean are drawn by the same package, but no
catalogue for them exists yet.
A script the image does not draw, or a font you prefer, goes in through the door the Dockerfile leaves open, which installs any apt package into the runtime stage:
docker compose -f docker/compose.yml -f docker/compose.build.yml build --build-arg POSTULO_EXTRA_APT_PACKAGES=fonts-noto-extra
Without a container, sudo apt install fonts-noto-core fonts-noto-cjk beside Pango does
the same.
Wherever Postulo runs, ask it what it can draw before a document is sent:
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, running the registry image (the Compose files' default):
With Docker, building from source (a fork or a patch, or plugins baked into the image), and only then:
git pull
docker compose -f docker/compose.yml -f docker/compose.build.yml build --pull \
--build-arg POSTULO_APT_REFRESH="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker compose -f docker/compose.yml -f docker/compose.build.yml up -d
If you baked plugins in (see Plugins), add -f docker/compose.plugins.yml to both lines:
that file is what carries them, and an instance with a baked image is never upgraded with
pull.
The build argument is what makes the image take Debian's security updates again: without it a machine that has built before hands back the packages it installed then. The reason is under The image and Debian's updates.
Migrations run on start, so there is no separate step to remember.
The classification files live on the data volume and survive a rebuild; there is nothing to run again.
Without, with the same flags as the install (--locked --no-default-groups --extra server,
and --extra postgres if you use it), so the upgrade does not install the test tools or
remove gunicorn:
git pull
uv sync --locked --no-default-groups --extra server
uv run --no-sync manage.py migrate
uv run --no-sync manage.py collectstatic --noinput
# restart your server and the scheduler
Releases and upgrading¶
Company and department names. From this release two companies of one account (or two
departments of one company) cannot share a name once capitals and spacing are ignored:
Acme and acme, Émile and ÉMILE. If your data already has such a pair the upgrade
stops, and the container will not start, with a message that lists them (owner, ids, names).
Before upgrading, run manage.py company_name_clashes, which lists them and changes nothing.
Merge each pair on the company's page with Merge, or rename one of the two departments,
then upgrade. Nothing is merged for you.
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.3. There is one
upgrade path per kind of install; use the one that matches how you installed, never both.
The published image (the default):
git fetch --tags && git checkout vX.Y.Z # the new release tag
docker compose -f docker/compose.yml pull
docker compose -f docker/compose.yml up -d
Built from source, with the build override, which tags the image postulo:local and
always builds it:
git fetch --tags && git checkout vX.Y.Z # or `git pull` to follow main
docker compose -f docker/compose.yml -f docker/compose.build.yml up -d --build
With plugins baked into the image (see Plugins), add -f docker/compose.plugins.yml to the
line: that file carries the build argument, and an image built without it has no plugin in
it. Such an instance upgrades by rebuilding, never by pull.
For PostgreSQL use docker/compose.postgres.yml and docker/compose.postgres.build.yml the
same way. Because a source build is named postulo:local, docker compose pull can never
replace it with the registry's release, nor the reverse. Migrations run when the container
starts, whichever way the image was made.
If you change Postulo's code — a fork, a patch in your own image — set
POSTULO_SOURCE_URL to where that code can be had: the AGPL entitles the people using
your instance to it, and Source code at the foot of every page is where the offer is
made (see Configuration).
Which version you are running is at the foot of every page once you are signed in,
linked to that release's notes; 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 asks the project nothing unless you say
so (the first start downloads the GeoNames city table, which POSTULO_SKIP_GEONAMES=1 turns
off, and an upgrade reinstalls plugins; see Hardening); manage.py check_for_updates asks once, by hand. The other way is to
watch the releases page or its feed yourself.