11 Installing Postulo
Tiago Águeda edited this page 2026-09-24 15:42:09 +02:00

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

  1. Clone and install, as above, but without the development extras:

    uv sync --no-dev
    
  2. 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.

  3. 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
    
  4. Check your configuration. This is worth doing before you expose anything:

    uv run manage.py check --deploy
    
  5. 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.)

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