Skip to content

Deployment

This page covers running and operating altero. For a first local test, Getting started is shorter.

Choose how to run altero

Method Best for Database
Docker Compose Easiest evaluation and normal self-hosting PostgreSQL
Source checkout Development or direct Python deployment SQLite by default; PostgreSQL optional

For a server used by several people, prefer PostgreSQL.

Database requirements

Full-text search is answered from an index each database builds with features that are not always installed. altero requires them:

Database Requires
SQLite 3.34 or newer, built with FTS5. It is the SQLite Python links: python3 -c "import sqlite3; print(sqlite3.sqlite_version)".
PostgreSQL The pg_trgm and btree_gin extensions, from PostgreSQL's contrib modules. Some distributions package those separately, for example as postgresql-contrib.

The Docker image and the Compose stack meet both.

The migration creates the two PostgreSQL extensions itself. Both are trusted since PostgreSQL 13, so the database's owner may create them. If altero's database user does not own the database, or PostgreSQL is 12 or older, have a superuser run this in altero's database first:

CREATE EXTENSION pg_trgm;
CREATE EXTENSION btree_gin;

Without either requirement the migration stops and names what is missing; altero does not start against the old schema.

The index costs disk, and building it costs time once. Measured with 30,000 attachment texts holding 180 MB of text:

Database Index on disk Built by the migration in
SQLite 477 MB, about 2.6 times the text ~65 s
PostgreSQL 90 MB, about half the text ~70 s

Each later full-text upload takes a few milliseconds longer to index.

Docker Compose

The image is published as ghcr.io/eseifert/altero, so running altero needs no checkout and no build. The Compose file on its own is the deployment:

mkdir altero && cd altero
curl -fsSLO https://raw.githubusercontent.com/eseifert/altero/v1.0.0-beta.7/docker/compose.yaml
ALTERO_IMAGE_TAG=1.0.0-beta.7 docker compose up -d
docker compose exec altero altero user add <username>
docker compose exec altero altero user password <username>

From a repository checkout, name the file where it lives instead:

ALTERO_IMAGE_TAG=1.0.0-beta.7 docker compose -f docker/compose.yaml up -d
docker compose -f docker/compose.yaml exec altero altero user add <username>
docker compose -f docker/compose.yaml exec altero altero user password <username>

The stack contains PostgreSQL, altero and persistent attachment storage.

altero user add creates the account without a password, so altero user password follows it. The alternative is to create the first account in the browser: registration is open while the instance has no accounts at all, and the account that claims it administers the instance. See Accounts.

latest is the newest release, prereleases included. ALTERO_IMAGE_TAG selects another: 1.0.0-beta.7 pins this version, and dev follows master. Persist the pin in .env beside a downloaded Compose file, or in docker/.env for a checkout. The matching release documentation is at https://altero.run/1.0.0-beta.7/; the docs site's latest alias is managed separately from the container tag.

The altero API is published on the loopback interface by default. Put a TLS terminator or reverse proxy in front of it rather than exposing the application port directly.

There are two ports. altero listens on ALTERO_PORT inside the container, 8000 unless changed; ALTERO_PUBLISH_PORT is the host port Compose publishes it on, also 8000. To move the address clients use, change ALTERO_PUBLISH_PORT and leave ALTERO_PORT alone.

Production settings to change first

Before putting real data on the instance:

  1. Set a real POSTGRES_PASSWORD.
  2. Set ALTERO_PUBLIC_URL to the URL users will actually open.
  3. Terminate TLS in front of altero.
  4. Decide whether outgoing email is required; see Email.
  5. Name the reverse proxy in ALTERO_FORWARDED_ALLOW_IPS; see Behind a reverse proxy.

Container settings belong in docker/.env, beside docker/compose.yaml.

To see the values Compose resolved:

docker compose -f docker/compose.yaml config

Upgrade altero

Back up the database and attachment storage together before upgrading; see Backups. Upgrading from alpha.3 or earlier advances group-library versions once, so existing desktops refresh their cached group permissions. It does not change personal-library versions. See desktop compatibility coverage and remaining gaps for the verified sync behavior.

docker compose -f docker/compose.yaml pull altero
docker compose -f docker/compose.yaml up -d

An instance pinned with ALTERO_IMAGE_TAG upgrades when that value changes, not when pull runs.

Upgrading from beta.6 to beta.7 needs no database or storage changes.

Database migrations run when altero starts. If a migration fails, the application container exits instead of serving against an incompatible schema.

The next upgrade after beta.7 repairs child notes and attachments incorrectly filed in collections (issue #64). Sync each desktop after upgrading. Affected items receive a new version so clients download the repair; their parents and attachment files stay intact. Source installations must run uv run alembic upgrade head before serving.

Direct upgrades from beta.1 through beta.7 to the current development schema passed with populated personal and group libraries on SQLite and PostgreSQL. The checks include attachment bytes, full-text search, versions, permissions and repeat startup; see the upgrade matrix and limits.

Upgrading from beta.2 or earlier builds the full-text search index once: about a minute per 30,000 attachment texts, and more disk. Check the database requirements first.

Upgrading from beta.5 or earlier renames attachment storage once, on the first start: every attachment file is read and hashed, so starting takes about as long as reading the storage directory from disk. Web snapshots are renamed to the digest of their own bytes. A file on a mount that cannot make hard links is copied first, so keep free space for your largest snapshot. Do not downgrade afterwards without restoring the storage backup as well.

To update only the PostgreSQL image within the currently compatible major version:

docker compose -f docker/compose.yaml pull db

Upgrade PostgreSQL across a major version

altero's migrations do not upgrade PostgreSQL's own on-disk data format.

Moving a PostgreSQL volume to a new major version requires a dump and restore, for example with pg_dump from the old server and psql into the new one. The PostgreSQL image will refuse an incompatible data directory rather than silently adopting it.

Build the image instead of pulling it

A change to the source, the Dockerfile or the web interface wants a locally built image. docker/compose.build.yaml adds the build to the same deployment:

docker compose -f docker/compose.yaml -f docker/compose.build.yaml up -d --build

It builds altero:local, deliberately not the published name, so that a later docker compose pull cannot replace a built image without saying so. Each build fetches the current base images and uv rather than reusing cached ones. Both files carry the same project name, so the built stack uses the volumes an earlier pulled one wrote.

Try the image on its own

For a quick single-user trial, the image runs without Compose or PostgreSQL:

docker run -d --name altero -p 127.0.0.1:8000:8000 -v altero-data:/data ghcr.io/eseifert/altero:latest
docker exec altero altero user add <username>
docker exec altero altero user password <username>

The SQLite database is kept on the /data volume, beside the attachments. ALTERO_DATABASE_URL selects another database, as the Compose stack does. For a server used by several people, use the Compose stack instead.

Podman

The image runs under Podman unchanged. Three things differ from Docker.

It runs as UID 10001. /data inside the image belongs to that user. A named volume inherits the ownership; a bind mount arrives with the host's instead, and altero then cannot write attachments. Under rootless Podman, hand the directory to the mapped user before starting the container:

podman unshare chown -R 10001:10001 /srv/altero/storage

On SELinux systems, mount it with :Z so the container gets a matching label.

The port is the one above. The container's own health check follows ALTERO_PORT, so an instance that moves it stays healthy; publishing 8090:8000 on the host needs no ALTERO_PORT at all.

The health check needs the Docker image format. Podman reads a health check out of an image only in that format; an OCI image carries no such field, and podman healthcheck run then answers has no defined healthcheck. The published image uses the Docker format, but an image built locally does not unless it is asked to:

podman build --format docker -f docker/Dockerfile .

podman-compose runs docker/compose.yaml as it is. For Quadlet, a systemd unit or a NixOS virtualisation.oci-containers module, the settings are the ones Configuration lists, plus ALTERO_DATABASE_URL pointing at the PostgreSQL container over a shared network.

A worked NixOS example, with altero and PostgreSQL as virtualisation.oci-containers units on a shared Podman network, is posted in Discussion #5. It is one contributor's running configuration rather than a reference deployment.

The published beta.7 amd64 image passed a rootless Podman run on Gentoo with Podman 6.1.4: browser edits and attachment bytes persisted across container replacement, and the health check passed with internal ports 8000 and 8091. This was a named-volume SQLite deployment; bind mounts, SELinux, PostgreSQL, Compose and Quadlet need separate evidence. Follow the same operator procedure to contribute another result.

From a source checkout

Requirements:

  • Python 3.14.10 or newer — earlier releases still trust the eMudhra intermediate certificates whose keys were exposed in October 2026;
  • a database meeting the database requirements;
  • uv 0.13.4 or newer, for the same reason; and
  • Node 24.22.1 or newer and npm to build the browser interface; earlier releases carry the Brotli decoder flaw CVE-2026-111497 and the eMudhra certificates.

Source installations also need ICU development libraries, pkg-config and a C++ compiler to build PyICU, used for duplicate PDF matching. On Debian or Ubuntu, install libicu-dev pkg-config g++ before uv sync. The Docker image includes the ICU runtime. See PyICU's installation instructions for other systems.

SQLite is the default. For PostgreSQL outside Docker, install the extra dependency:

uv sync --extra postgres

For a basic SQLite installation:

git clone --branch v1.0.0-beta.7 https://github.com/eseifert/altero.git
cd altero
uv sync --locked
npm --prefix web ci
npm --prefix web run build
cp config.example.py config.py
uv run alembic upgrade head
uv run altero user add <username>
uv run altero user password <username>
uv run altero

The default address is:

http://127.0.0.1:8000

You can create an API key directly if needed:

uv run altero key add <username> --name laptop

The key is printed once and cannot be displayed again.

What a small instance costs

Measured on x86-64 from a source checkout, Python 3.14 and SQLite, with the interface built:

What Size
altero, idle after start ~125 MB resident
altero, after light API traffic ~130 MB resident
Peak during start-up and migration ~133 MB resident
An empty database, schema only 0.7 MB
The installed Python environment ~380 MB

The container runs the same interpreter and the same packages, so the application's own use is the figure above. Two things sit on top of it and are not measured here: the PostgreSQL container in the Compose stack, and the image on disk.

Attachments are what grows. They are stored once per digest, so a file two libraries hold is on disk once; a library's nominal and real usage are reported per library under Administration → Storage.

Attachment storage

An attachment is written under a temporary name and then moved onto the path named after its digest, so an upload that fails leaves no file rather than a short one. That relies on the move being atomic, which it is on any ordinary filesystem, including NFS and SMB mounts.

Warning

Do not point ALTERO_STORAGE_PATH at object storage mounted as a filesystem. s3fs, goofys and rclone implement a rename as copy-then-delete, and some refuse to rename over an existing file at all. altero will not fall back to copying — a copy onto the final path is the torn write it is avoiding — so uploads fail there and clients retry them indefinitely. Keep the store on a block-backed filesystem and back that up to object storage instead.

Health check

GET /health is the readiness endpoint and is also used by the container health check.

A successful response includes the application version, API version, schema version and database migration revision, for example:

{
  "status": "ok",
  "version": "1.0.0b7",
  "apiVersion": 3,
  "schemaVersion": 42,
  "revision": "d7cd57abc8a4"
}

If the database is unavailable, the endpoint returns 503 with:

{"status": "error"}

Because /health is unauthenticated, it intentionally does not expose the database failure details.

Configuration

Settings come from three places, in increasing order of precedence: the built-in defaults, a config.py module, and ALTERO_-prefixed environment variables.

cp config.example.py config.py                  # a source installation
ALTERO_PORT=9000 ALTERO_DEBUG=true uv run altero  # or a one-off override

In the Compose stack, every ALTERO_ setting in docker/.env reaches the container.

Configuration lists every setting with its default, and how to set it in each of those places.

Public URL

ALTERO_PUBLIC_URL should be the stable external URL of the instance, for example:

https://altero.example.org

API object links and attachment upload/download URLs use this address, including its HTTPS scheme, even when a reverse proxy connects to altero over HTTP. When it is unset, those URLs use the request's address instead.

It becomes required in practice when you use features that generate callbacks or bind credentials to a host:

  • OpenID Connect or SAML sign-in;
  • passkeys;
  • the authorization server, which refuses to serve its endpoints rather than guess the iss claim — see Connecting other applications;
  • links in outgoing email.

Changing the host of ALTERO_PUBLIC_URL invalidates existing passkeys. Changing only the scheme or port does not.

Outgoing email

Email uses:

  • ALTERO_SMTP_URL;
  • ALTERO_MAIL_FROM; and
  • ALTERO_PUBLIC_URL.

Without an SMTP relay, most messages are written to the log instead. Self-service password reset is the exception: it is not enabled without a relay because a password-reset link must not be exposed in server logs.

See Email for relay URLs, Docker details, security notices and group digests.

Single sign-on

OpenID Connect and SAML 2.0 providers are configured in Administration → Sign-in providers.

Set ALTERO_PUBLIC_URL first. The callback/redirect address shown in the administration screen is built from that value and must match the address registered with the identity provider.

See Administration.

Group notifications

Group notifications are opt-in per member and per group.

Two settings control digest delivery:

ALTERO_GROUP_DIGEST_QUIET_PERIOD=900
ALTERO_GROUP_DIGEST_INTERVAL=60

ALTERO_GROUP_DIGEST_INTERVAL=0 disables delivery. Activity is still recorded, so it can be delivered after the feature is re-enabled.

See Email.

Behind a reverse proxy

Four things have to be right, and a default configuration gets two of them wrong.

Attachment uploads need a large request body. altero accepts a file of up to 1 GiB, or ALTERO_MAX_UPLOAD_MB. nginx allows 1 MB by default and answers 413 above it, which rejects most PDFs; Zotero reports the attachment as failing to sync. Caddy and Traefik impose no limit of their own.

/stream is a WebSocket. It needs the upgrade headers, and a read timeout longer than an idle connection. The connection is not silently idle — the server pings every 20 seconds — so a 60-second timeout is already enough, but a proxy that drops the upgrade entirely leaves Zotero without live updates. Worse, a streaming URL that does not resolve is why Connecting a Zotero client insists on setting extensions.zotero.streaming.url: left at its default, the client sends the altero API key to zotero.org.

/web/libraries/<id>/events is a stream that does not end. It is how an open browser page hears of changes made elsewhere, as Server-Sent Events. It sends something every 25 seconds, so a 60-second read timeout is enough, and it must not be buffered: nginx honours the X-Accel-Buffering: no altero sends, and a proxy that buffers anyway delivers the changes late or not at all. Like /stream, it works within one server process: run several workers and a page hears only of changes made through its own.

Forwarded headers are believed only when a proxy is named. Set ALTERO_FORWARDED_ALLOW_IPS to the address the proxy connects from. Until then altero ignores X-Forwarded-Proto and X-Forwarded-For, which has two effects:

  • Behind a TLS terminator, the request appears to use http://. Set ALTERO_PUBLIC_URL so API object links and attachment upload/download URLs use the external HTTPS address. Without it, these URLs depend on the forwarded scheme and request host.
  • altero sees the proxy's address rather than the client's, and both rate limiting and the “last used from” shown for an API key report one address for everybody.

Name only a proxy that overwrites X-Forwarded-For. A trusted proxy that passes a client-supplied header through lets the caller choose the address attributed to it. Pass the external Host header through unchanged so request-based URLs, including pagination links, use the public host.

In the Compose stack the proxy is a container, so the value is that container's address on the Docker network — not 127.0.0.1, which is altero's own container.

Set ALTERO_PUBLIC_URL to the URL people actually open. See Public URL; passkeys in particular are bound to that host.

The proxy need not compress anything. altero sends the browser interface's scripts and stylesheets in the Brotli or gzip copies its build writes, and leaves API responses as they are.

Don't cache /app/sw.js or /app/manifest.webmanifest. altero sends them with Cache-Control: no-cache, because a new service worker is how an installed app learns of an upgrade. A proxy that keeps a copy holds every installed app on the old version.

Leave altero's security headers in place. Every response carries X-Content-Type-Options: nosniff and a Content-Security-Policy: the interface's own under /app, and sandbox everywhere else, so an attachment opened in a browser tab runs no script. A proxy that replaces or strips them undoes that. Strict-Transport-Security is the proxy's to add.

nginx

server {
    listen 443 ssl;
    server_name zotero.example.org;

    # Attachments. The default is 1m, which rejects most PDFs with 413.
    client_max_body_size 1024m;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        # Overwritten, not appended: $proxy_add_x_forwarded_for would carry a
        # client-supplied header through, and altero believes this one.
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # The streaming API. Same upstream, but the upgrade has to be passed on
    # explicitly and the connection lives longer than a request.
    location /stream {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;
    }
}

Caddy

zotero.example.org {
    reverse_proxy 127.0.0.1:8000 {
        # Caddy appends the client to an X-Forwarded-For it was handed;
        # replacing it is what makes trusting the header safe.
        header_up X-Forwarded-For {remote_host}
    }
}

Caddy needs nothing said about WebSockets or body size: it passes an upgrade through as it stands and imposes no limit of its own.

Traefik

labels:
  - traefik.enable=true
  - traefik.http.routers.altero.rule=Host(`zotero.example.org`)
  - traefik.http.routers.altero.entrypoints=websecure
  - traefik.http.routers.altero.tls.certresolver=letsencrypt
  - traefik.http.services.altero.loadbalancer.server.port=8000

Traefik proxies the WebSocket without configuration and imposes no body limit. It replaces X-Forwarded-For with the address the connection came from unless forwardedHeaders.trustedIPs is set on the entry point, which is the behavior to keep.

Reaching altero by container name means both containers share a network, and the ports: publication in docker/compose.yaml is then unnecessary — remove it rather than exposing the application port beside the proxy.

Tailscale

tailscale serve --bg 8000

tailscale serve terminates TLS for the machine's tailnet name, passes Host through, and sets X-Forwarded-Proto and X-Forwarded-For itself rather than appending to what the client sent. It connects from 127.0.0.1, so:

ALTERO_FORWARDED_ALLOW_IPS=127.0.0.1
ALTERO_PUBLIC_URL=https://<machine>.<tailnet>.ts.net

Under Docker Compose, altero sees the Docker network's gateway instead of 127.0.0.1; the access log shows the address to name.

Rate limiting

The optional general request limiter is off by default. Password and second-factor sign-in have separate, always-on protection described below.

Example: allow 600 requests per 60 seconds per API key:

ALTERO_RATE_LIMIT=600 ALTERO_RATE_LIMIT_WINDOW=60 uv run altero

Requests over the limit receive 429 with a whole-second Retry-After, which Zotero understands.

Unauthenticated requests are counted per client address. So are requests with a key that has not authenticated in the current window, as well as per key: made-up keys share their address's allowance. /health is never rate limited.

The limiter is process-local. With several application workers, each process has its own allowance. Use the reverse proxy or another edge component if you need a deployment-wide defensive limit.

Sign-in backoff

Password sign-in and TOTP/email second-factor guesses share database-backed budgets across workers and restarts. This protection is enabled even when ALTERO_RATE_LIMIT=0. Run alembic upgrade head when upgrading from beta.7; the new login_attempts table contains counters, not passwords or codes.

The first five attempts per account and twenty per client address can proceed without waiting. The fifth/twentieth reservation starts a one-second pause before another attempt; each further admitted guess doubles it to a maximum of five minutes. 429 answers carry a whole-second Retry-After. Requests refused during the pause do not extend it. Fifteen minutes without an admitted attempt reset the counters; expired rows are removed by the next reservation.

Password and second-factor account budgets are separate. Username/email aliases share the account's password budget; TOTP and email share its factor budget across pending sessions and resends. Unknown names get the same limits. A successful proof resets its account budget and refunds only its own address attempt, preserving other failures. Concurrent attempts count before checking the credential; an interrupted verification counts conservatively as a failure.

The address comes from the connection after uvicorn's trusted-proxy processing. Configure forwarded headers correctly: an unrecognized proxy makes all its users share one address budget. The application never reads an untrusted X-Forwarded-For directly. Passkey/SSO ceremonies, existing browser sessions and v3 API credentials do not use these password/code budgets.

Moving or restoring data

For whole-library export/import, migration from zotero.org and recovery after recreating a database, see Administration.

Backups

Keep the database and attachment storage as one consistent backup. Stop altero and other writers while taking it; for Compose, stop the application with docker compose -f docker/compose.yaml stop altero and leave PostgreSQL running. Use pg_dump for PostgreSQL, or copy the SQLite database while altero is stopped, and copy the attachment storage before restarting the application.

Keep configuration and the secrets needed to operate the instance with the backup. Test a restore in a separate instance before relying on it. A library archive preserves one library's data and files; it does not contain instance accounts or API keys.