A client asked for seat assignments on their ticketing setup. The stack is self-hosted Pretix on Docker Swarm. That should be a small job: pull a plugin from the marketplace, install it, move on.
It was a small job. It also surfaced a cronjob warning that had been sitting in the admin panel for a while, ignored.
This post is about both.
Picking the right plugin
The Pretix marketplace has a plugin called "seatmaps" in the URL slug of one product. The actual package name is something else.
When I searched pip install pretix-seatmaps, pip returned No matching distribution found. PyPI 404. GitHub 404. Every variant under pretix.eu/... was 404 too.
The product page on the marketplace told me the real name. It called itself manualseats. PyPI returned it on the first try:
$ curl -s https://pypi.org/pypi/pretix-manualseats/json | jq -r '.info.name + " " + .info.version'
pretix-manualseats 1.0.5
Author Moritz Lerch, Apache 2.0, repo on GitHub. That's the one I wanted.
The lesson is boring and worth repeating: search the package name in the wheel index before you trust a marketplace URL.
The install pattern that actually works
Pretix standalone ships its own Python environment inside the container. Third-party plugins are not in /usr/local/lib/python3.13/site-packages/pretix/plugins/. You don't pip install them into the image.
The pattern that works is:
- Install the wheel into a mounted volume so it survives a redeploy.
- Add that volume to
sys.pathfor the running container.
In Swarm terms:
volumes:
- pretix_data:/data # already there for pretix itself
configs:
pretix_cronjob_v1:
file: ./cronjob.conf
Inside the container, after docker exec:
pip install --target=/data/plugins pretix-manualseats
Then the running process needs to see it. The standalone image runs under supervisord, and supervisord passes PYTHONPATH to its children. So:
services:
web:
image: pretix/standalone:stable
environment:
- PYTHONPATH=/data/plugins
This is the line that makes the difference between "wheel exists on disk" and "Pretix loads it as a plugin".
After the stack redeploys, pretix migrate runs as a no-op. The plugin has no migrations of its own.
Did it actually load?
This is the part I almost skipped. The wheel is on disk. import pretix_manualseats works. Pretix migrate doesn't complain. So it's loaded, right?
Not quite. The plugin can be installed and still not be wired into Pretix. The thing to check is Pretix's own plugin registry, not just Python's import system.
$ docker exec -i <container> /usr/local/bin/pretix shell -c "
from pretix.base.plugins import get_all_plugins
plugs = list(get_all_plugins())
manualseats = [p for p in plugs if 'manualseat' in p.module.__name__.lower()]
print(len(manualseats), manualseats[0].name, manualseats[0].version)
"
1 Manual Seats 1.0.5
That is the line that confirms Pretix recognises the plugin. If get_all_plugins() returns it, the admin UI will show it, the URLs will route, and the signals will fire.
If you only check importlib.metadata.entry_points(group="pretix.plugin"), you're checking Python's idea of "what's installed." Pretix has its own idea. Use Pretix's idea.
The cronjob that wasn't running
Pretix has an admin warning that reads, more or less:
The cronjob component has not run in the last hours. Please check your installation.
I had seen it before. I had assumed it was a config issue I would come back to. Today I came back to it.
The stack runs Pretix under supervisord, with four programs: pretixweb, pretixtask, cronjob, nginx. The cronjob program was RUNNING. Its command was:
/bin/sh -c "while sleep 300; do /usr/local/bin/python3 -m pretix periodic_task; done"
That looks fine. The shell loop is running. supervisord sees a live process.
It wasn't fine. pretix periodic_task is not a valid subcommand in Pretix 2026.7.0. The list of subcommands includes runperiodic, runserver, shell_scoped, migrate, and others. periodic_task is not there.
Each time the loop iterated, the inner command failed with Unknown command: 'periodic_task'. The next iteration began anyway, because the shell loop kept going. supervisord stayed RUNNING because the outer process was alive. But last_cronjob_run in the database never moved, so the admin kept complaining.
That's the shape of this kind of bug. Nothing is broken. Nothing is logging an error. The whole stack is up, the warning just sits there.
The fix is one word. Replace periodic_task with runperiodic in the cronjob command.
runperiodic is the Django-celery-beat management command that pretix uses in this version. It updates last_cronjob_run in the DB on every successful pass.
Making the fix stick
Editing the file in /etc/supervisord/cronjob.conf inside the container works for the moment. It does not survive a docker service update. The image owns those files.
The clean way in a Swarm stack is to override the config through the same mechanism Pretix's own configuration uses: a configs: entry in the compose file.
configs:
pretix_cfg_v4:
file: ./pretix.cfg
cronjob_v1:
file: ./cronjob.conf
services:
web:
configs:
- source: pretix_cfg_v4
target: /etc/pretix/pretix.cfg
mode: 0444
- source: cronjob_v1
target: /etc/supervisord/cronjob.conf
mode: 0444
./cronjob.conf in the repo holds the corrected command. Swarm ships it into the container on every redeploy. supervisord picks it up.
One assumption worth stating: this only works because the pretix/standalone image does not regenerate /etc/supervisord/*.conf on container start. I checked /proc/1/cmdline (it's supervisord -n, no entrypoint script in front of it) and ls -la /etc/supervisord/ (regular files, not symlinks to templates). If the image ever changes that, the override stops being an override.
The deploy itself rotated the container: 4c6ddbcab25c was replaced by 57399739f382. The new container reads the new cronjob.conf, the cronjob command is pretix runperiodic, and a manual run returns exit 0.
What I'm not fixing today
The DNS record for ticket.falitech.com still resolves to Cloudflare's IPs, not to the Swarm manager. Traefik has the router configured with Host(`ticket.falitech.com`), but no traffic has arrived yet. Let's Encrypt is waiting for the first hit, which can't happen until the A record points the right way.
That's outside my reach. Whoever owns the falitech.com zone adds ticket → 158.220.100.146 and the rest falls into place.
I cannot test the seat assignment UI end-to-end yet. What I can confirm is the registry check above, the supervisord status, and the fact that runperiodic runs without error.
A note on cadence
The last article on this blog went out in mid-June. That's roughly seven weeks of silence.
Honest reason: I let the cadence break. I had a few drafts parked. None were ready. Posting half-finished things to keep the calendar moving felt worse than waiting, so I waited, and the gap got longer, and waiting felt worse. That loop is easy to fall into and harder to leave.
I'd rather publish something real than something on schedule. I'm back to a weekly-ish rhythm now. This is the first of more to come.
If you're reading this in your inbox or through the social post: thanks for hanging in through the gap.
What I'd take away
Three things, if I'm being honest about today:
- The marketplace URL is not the package name. Check PyPI before you assume.
- Install + entry point +
importis not the same as "Pretix sees it." Usepretix.base.plugins.get_all_plugins()to confirm. - A RUNNING process that is calling a missing command is the worst kind of silent failure. Supervisord is happy. The admin warning is the only signal, and it's easy to ignore.
The plugin works. The cronjob will be quiet by tomorrow morning. The DNS record is someone else's problem.