Ir para o conteúdo

Installing Postulo

Esta página ainda não foi traduzida, por isso é mostrada em inglês.

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.sh repeats 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:

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 the .env at the checkout's root first (both containers read it; any character is allowed in it):

docker compose -f docker/compose.postgres.yml up -d

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:

./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; 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.

docker compose -f docker/compose.yml exec postulo python manage.py fetch_esco --revision 1.2.1

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

  1. Clone and install, as above, but without the development and browser-test groups, and with the server extra (that is where gunicorn comes from):
uv sync --locked --no-default-groups --extra server

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.

  1. 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:

python -c "import secrets; print(secrets.token_urlsafe(64))"

Every setting is listed in Configuration.

  1. 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
  1. Check your configuration. This is worth doing before you expose anything:
uv run manage.py check --deploy
  1. Run it with a real server. Postulo is a standard WSGI application at postulo.config.wsgi:application. For example, with gunicorn:
uv run --no-sync gunicorn postulo.config.wsgi:application --bind 127.0.0.1:8000 --workers 3

gunicorn is Postulo's own server extra, installed in step 1; any other WSGI server will do if you prefer one.

  1. 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.webmanifest and /sw.js through from the root of the site, and be HTTPS, for people to be able to install Postulo as an app (Installing the app).

  2. 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 ExecStart is

uv run --no-sync manage.py send_due_reminders --loop

(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.

  1. Once, for the place search, download the GeoNames cities Postulo looks a town up in:
uv run --no-sync manage.py fetch_geonames

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:

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, 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:

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, running the registry image (the Compose files' default):

docker compose -f docker/compose.yml pull
docker compose -f docker/compose.yml up -d

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.