BlogGuides

How to self-host pretix ticketing on your own Ubuntu VPS

Run pretix v2026.8.0, the open source ticket shop, on your own Ubuntu server with ox and no Docker: Postgres, Redis, Celery and a media server, as it ran.

On this page

To self-host pretix you need an Ubuntu server, a fork of the pretix repo with an ox.toml and one small Python file added, and 19 variables, most of them fixed values or references that ox fills in. In our run the first deploy took about 5 minutes, and ox set up Postgres and Redis for it.

How we tested On Oct 17, 2026, ox deployed pretix v2026.8.0 from commit f7863c9f on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory. The run then redeployed it, backed it up and rebooted the server. Then a separate test agent, one that did not write the config, signed in, created an organizer and an event, and saw the event listed after a reload, and checked the app's services and its backup.

pretix is ticket sales software for events. An organizer sets up an event in the control panel, and ticket buyers use a shop page for that event. It is a Django app: Postgres holds the data, Redis holds the cache, the sessions and the task queue, and a Celery worker sends mail and runs background tasks such as exports.

The code is under the GNU AGPL version 3 with additional terms: the pretix notice stays at the bottom of its pages, and offering pretix as a service to others has conditions. Read the LICENSE file first if that is your plan.

ox deployed pretix v2026.8.0 (commit f7863c9f) on Oct 17, 2026 on a fresh DigitalOcean server with 8 vCPUs and 16 GB of memory; the first deploy took 5 minutes and a redeploy took 2 minutes.

  • First deploy: 5 minutes, from adding the project until the app passed the run's first checks.
  • Redeploy of the same commit: 2 minutes, until the app passed the checks again.
  • Server: DigitalOcean, 8 vCPUs and 16 GB of memory (s-8vcpu-16gb), with this app alone on it. Every one of the 85 apps we deployed ran on this one size, so it was not picked for this app.
  • Memory in use after the reboot: 8565 MB of 15986 MB, for the whole server: Ubuntu, the app and its staging copy, its services, Caddy and the ox agent. On a server with no apps, ox's tests on real servers hold the agent to 40 MB or less and Caddy to 50 MB or less.
  • Services ox runs for it: postgres, redis.
  • Background processes: mediafiles, worker, each its own systemd unit beside the web process.
  • Scheduled jobs: runperiodic.
  • Source: github.com/pretix/pretix at v2026.8.0, commit f7863c9f.
The pretix log-in page
The pretix log-in page

What you need

  • An Ubuntu LTS server where you can run one command as root. The run used the size in the facts above. ox works on any server from 1 GB up and tells you when memory is why an app stopped.

  • A GitHub account to fork pretix/pretix, and an ox account, which you open by signing in with GitHub.

  • A mail server, if you want pretix to send mail. The run sent its mail to a test catcher, so a real provider was not tested. Selling a ticket, payment providers and the check-in apps were not tested either.

The steps

In the ox dashboard, press Add a server. It shows one install command with a fresh token in it. Run it as root on the server.

Fork pretix/pretix and check out the tag v2026.8.0, the release the run deployed. Add the ox.toml at the root and the Python file at deployment/ox/media.py, both shown below, then commit and push. Write a file named pretix.env on your laptop with the 19 keys from the variables section. The secret key is yours to make: the run used a random hex string, and openssl rand -hex 32 makes one. With the ox CLI signed in, run:

Shell
ox new <you>/pretix
ox review pretix --from-file pretix.env --wait

The review saves your values and starts the first deploy, and --wait prints the run's log until it ends. The browser has the same two steps, which the quickstart shows.

When the deploy is green, open the address ox shows. The page only says "Hello!" and links to /control/, the control panel. Sign in there as admin@localhost with the password admin. pretix's first migration makes that user, so no variable sets it. Every install starts with this password, and pretix's docs expect you to change it after the first sign-in. Until you do, anyone who reaches the address can sign in with it.

Press Admin mode in the top bar, because pretix refuses to create an organizer without it. Open Organizers, choose Create a new organizer and name it, then create an event with a name, a short form and a date. The test agent did this and, after a reload, found the event in the Events list, marked Shop disabled.

The run used the preview address only. A domain of your own was not part of it, and the domains page covers how ox adds one.

The ox.toml, line by line

TOML
# pretix on ox, following the Docker image (Dockerfile, deployment/docker/).
# Settings come from PRETIX_<SECTION>_<OPTION> variables (src/pretix/helpers/config.py).

# gettext: compilemessages and compilejsi18n; make: src/Makefile
packages = ["gettext", "make"]
required_variables = ["PRETIX_DJANGO_SECRET"]

[app]
start  = "cd src && exec gunicorn pretix.wsgi --name pretix --workers ${NUM_WORKERS:-$((2 * $(nproc)))} --max-requests 1200 --max-requests-jitter 50 --log-level=info --bind 127.0.0.1:$PORT"
health = "/healthcheck/"

[static]
paths = { "/static" = "src/pretix/static.dist" }

[build]
# The Dockerfile's pip install; PRETIX_DOCKER_BUILD leaves the asset build to make production
install  = "uv venv --python 3.13 .venv && PRETIX_DOCKER_BUILD=TRUE uv pip install --python .venv -e . gunicorn"
commands = [
  "cp deployment/docker/production_settings.py src/production_settings.py",
  "cd src && make production",
]
migrate  = "cd src && python -m pretix migrate --noinput"

[workers]
worker = "cd src && exec celery -A pretix.celery_app worker -l info"
# /media/: nginx's job in the Docker image, since Django serves media only in DEBUG
mediafiles = { run = "exec gunicorn --chdir deployment/ox media:application --workers 2 --bind 127.0.0.1:$PORT", port = true, paths = ["/media"] }

[cron]
# as in pretix's installation guide
runperiodic = { schedule = "15,45 * * * *", run = "cd src && exec python -m pretix runperiodic" }

[tools]
node   = "24"
python = "3.13"

[services]
postgres = {}
redis    = {}

[storage]
# DATA_DIR ("data", relative to src/): media, logs, cache
keep = ["src/data"]

The config follows pretix's Dockerfile and the scripts in deployment/docker, without the container. pretix reads each setting from a variable named PRETIX_<SECTION>_<OPTION>.

  • packages adds gettext for the translations and make for the build target. required_variables names the secret key. Without one pretix writes a random key into its data folder. With it, ox asks for the key before the first deploy and keeps it with your other variables.

  • start runs gunicorn from src/ on loopback and $PORT, with Caddy in front. It starts NUM_WORKERS web workers, else twice the CPU count, and restarts each after about 1200 requests, as pretix's Docker script does.

  • health is pretix's /healthcheck/. It reads the user table in Postgres and sets a key in Redis, and it errors when either is out of reach. ox switches traffic to a new release only after it answers. [static] serves src/pretix/static.dist, where collectstatic puts the assets, at /static.

  • install has uv make a Python 3.13 environment and install pretix from the checkout, plus gunicorn. PRETIX_DOCKER_BUILD=TRUE is the Dockerfile's switch that keeps pretix's setup from building assets, since make production follows. The build commands copy production_settings.py into src/, as the Dockerfile does, then make production compiles translations, runs npm ci and the front-end build, and collects and compresses the static files.

  • migrate runs pretix's migrations. The first one creates the admin user.

  • worker is the Celery worker, which pretix's Docker script calls the task worker. mediafiles serves uploaded files, and paths = ["/media"] sends every request under /media to it.

  • [cron] runs runperiodic at 15 and 45 minutes past each hour, UTC. pretix's docs say an outside scheduler has to call that command for its timed tasks.

  • [tools] has mise install Python 3.13, the Dockerfile's base version, and Node 24 for the front-end build. [services] asks for Postgres and Redis. [storage] keeps src/data, pretix's data folder with media, logs and cache, across deploys.

deployment/ox/media.py

Code
# Serves pretix's public media files (/media/) on ox, in place of the nginx
# location in deployment/docker/nginx.conf: Django serves media only in DEBUG.
# Like nginx, it refuses cachedfiles/ and invoices/, which pretix hands out
# itself through views that check permissions.
import mimetypes
import os

ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', 'src', 'data', 'media'))
PREFIX = '/media/'
DENIED = ('cachedfiles', 'invoices')


def _respond(start_response, status):
    body = status.encode()
    start_response(status, [('Content-Type', 'text/plain'), ('Content-Length', str(len(body)))])
    return [body]


def application(environ, start_response):
    path = environ.get('PATH_INFO', '')
    if environ.get('REQUEST_METHOD') not in ('GET', 'HEAD') or not path.startswith(PREFIX):
        return _respond(start_response, '404 Not Found')
    full = os.path.realpath(os.path.join(ROOT, path[len(PREFIX):]))
    if not full.startswith(ROOT + os.sep) or not os.path.isfile(full):
        return _respond(start_response, '404 Not Found')
    if os.path.relpath(full, ROOT).split(os.sep, 1)[0].startswith(DENIED):
        return _respond(start_response, '404 Not Found')
    ctype = mimetypes.guess_type(full)[0] or 'application/octet-stream'
    start_response('200 OK', [
        ('Content-Type', ctype),
        ('Content-Length', str(os.path.getsize(full))),
        ('Cache-Control', 'max-age=604800'),
        ('X-Content-Type-Options', 'nosniff'),
    ])
    if environ['REQUEST_METHOD'] == 'HEAD':
        return [b'']
    f = open(full, 'rb')
    return environ.get('wsgi.file_wrapper', lambda f, s: iter(lambda: f.read(s), b''))(f, 65536)

pretix serves media files itself only in debug mode, and the Docker image leaves them to nginx. This small WSGI app takes nginx's place as the mediafiles worker. It answers GET and HEAD under /media/, reads only the media folder in src/data, and refuses cachedfiles and invoices, as the image's nginx config does, because pretix serves those through views that check who is asking.

Shell
DJANGO_SETTINGS_MODULE
LC_ALL
PRETIX_PRETIX_URL
PRETIX_PRETIX_TRUST_X_FORWARDED_FOR
PRETIX_PRETIX_TRUST_X_FORWARDED_PROTO
PRETIX_DJANGO_SECRET
PRETIX_DATABASE_BACKEND
PRETIX_DATABASE_NAME
PRETIX_DATABASE_USER
PRETIX_DATABASE_PASSWORD
PRETIX_DATABASE_HOST
PRETIX_DATABASE_PORT
PRETIX_REDIS_LOCATION
PRETIX_REDIS_SESSIONS
PRETIX_CELERY_BROKER
PRETIX_CELERY_BACKEND
PRETIX_MAIL_FROM
PRETIX_MAIL_HOST
PRETIX_MAIL_PORT

Six keys are fixed values. DJANGO_SETTINGS_MODULE was production_settings and LC_ALL was C.UTF-8, as in the image. The two TRUST_X_FORWARDED keys were on, so pretix trusts the client address and https scheme the proxy passes on. PRETIX_DATABASE_BACKEND was postgresql, since pretix uses SQLite without it. PRETIX_REDIS_SESSIONS was true.

Nine are references. The five other PRETIX_DATABASE keys were ${postgres.database}, ${postgres.user}, ${postgres.password}, ${postgres.host} and ${postgres.port}. The Redis location and both Celery keys were ${REDIS_URL}. PRETIX_PRETIX_URL was ${PUBLIC_URL}, which pretix uses in its links. See variables for references.

Four are yours. PRETIX_DJANGO_SECRET is the Django secret key. PRETIX_MAIL_FROM was an address at example.com, and the mail host and port pointed at the test catcher. Nothing else was set, so pretix kept English, UTC, the euro and the instance name pretix.de.

Postgres, Redis and backups

Postgres is shared with other apps on the server, each with its own database and user. Redis runs only for this project. The run also deployed a staging copy, and the memory figure above counts both.

To update, merge a new tag into your fork and push, or run ox deploy pretix --wait. ox dumps Postgres before the migrations, and the run's redeploy left such a dump. Backups need no setup. Postgres and src/data had a daily copy and one from Back up now, and Redis had a daily copy. List them with ox backups pretix. A restore was not tested. The backups page covers copies in an S3 bucket.

Frequently asked questions

Can I run pretix without Docker?

Yes. pretix ships a Dockerfile, and its scripts show what the image runs: gunicorn, a Celery worker, a scheduled command and nginx in front. ox runs the same pieces on your server without a container. It builds pretix in a Python environment, starts each piece as a system service and serves files through Caddy.

Which database does pretix need?

Postgres. This release exits with a message on MySQL or MariaDB, and it uses SQLite when no backend is set. The run set the backend to postgresql and filled the other database keys from the Postgres service ox created. It also set up Redis, which pretix uses for its cache, its sessions and the Celery queue.

How do I sign in to a new pretix install?

Open /control/ and sign in as admin@localhost with the password admin. pretix's first migration makes that user, so no variable sets it, and its docs expect you to change the password afterwards. Turn on Admin mode in the top bar before you create an organizer. The test agent signed in with the default.

How much memory does self-hosted pretix use?

The facts above show the whole server's memory after a reboot, staging copy included. The start line runs two gunicorn workers per CPU unless NUM_WORKERS is set. The run's server had 8 CPUs and NUM_WORKERS was unset, so each copy ran 16. ox takes any server from 1 GB up and says when memory is why an app stopped.

New to running apps on your own server? How ox self-hosts your apps shows what ox sets up on your server.

Keep reading

Deploy to a server you own

ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free for your first project.

Sign up with GitHub Read the docs