Self-hosting

Run OrbSearch in production with Docker Compose on one machine, behind a proxy that terminates TLS.

OrbSearch runs as one Docker Compose stack, the compose.yaml at the repository's root. The stack you run in production is the one the repository tests: same services, same images, settings from one .env file. This page covers what runs, how to configure it, where your data lives and how to keep it safe.

What runs

Five services, two of them from one image. Compose builds the orbsearch and control images from the repository; Postgres and Meilisearch come from their official images.

ServiceImageListens on the hostVolumeJob
orbsearchbuilt from crates/orbsearch/Dockerfile127.0.0.1:7800storage, read-onlyThe Query API (orbsearch serve), which storefronts call
indexerthe same image-storage, read-writeIndex runs (orbsearch index): compiles a catalog snapshot and a release into Meilisearch and swaps them live
controlbuilt from apps/control/Dockerfile127.0.0.1:8000storage, read-writeThe control plane: the panel, the management API under /api/v1, and the schedule that fetches the feed URL
postgrespostgres:18.6-trixie127.0.0.1:5432postgresAccounts, releases, imports and index runs
meilisearchgetmeili/meilisearch:v1.54.3127.0.0.1:7700meilisearchThe search engine, holding only compiled indexes

Every part connects to Postgres with a role of its own: control owns the schema and its migrations grant indexer and query what each needs. The postgres superuser only creates these roles, once, when its volume is first initialized. The Query API also serves an internal port, 7801, that only the control plane reaches inside the stack's network; it is never published.

Every service restarts unless you stop it. The Query API and the indexer start once Postgres and Meilisearch are healthy; the control plane waits for Postgres.

Start it

You need Docker, make and openssl on the machine.

Write the settings

Terminal
make .env

This copies .env.example to .env and fills every empty secret with a fresh random value. Run it again after an upgrade: it adds only the settings .env lacks and never changes a value you have.

Build and start the stack

Terminal
docker compose up --build --detach --wait

--wait returns once every service reports healthy. The first build compiles the Rust binary and the panel, which takes a while.

Create the owner account

Open the panel at APP_URL (by default http://localhost:8000). The first person to open it creates the owner account; after that, sign-up is closed. Do it before the panel is reachable from the network (Security).

The stack runs, so the Quickstart takes you from its second step to the first search.

Settings

Every setting lives in .env. After a change, docker compose up --detach --wait recreates the services whose settings changed.

SettingWhat it does
APP_KEYThe control plane's encryption key: base64: followed by 32 random bytes. It encrypts the panel's cookies, two-factor secrets and the feed URL's credentials, so keep it.
APP_URLWhere merchants open the panel. Default: http://localhost:8000. An https:// address also makes the panel's session cookies secure.
POSTGRES_PASSWORDThe Postgres superuser, which only sets up the roles.
DB_PASSWORDThe control plane's role, control.
INDEXER_DB_PASSWORDThe indexer's role, indexer.
QUERY_DB_PASSWORDThe Query API's role, query.
MEILI_MASTER_KEYMeilisearch's master key, at least 16 bytes. Only the indexer holds it.
MEILI_SEARCH_KEYThe Query API's engine key, which may only search and list indexes. The indexer creates it from the master key at start; make .env derives it (Security).
ORBSEARCH_MANAGEMENT_KEYThe key of the management API, sent as Authorization: Bearer <key>.
PUBLISH_ADDRESSThe host address the panel and the Query API listen on. Default: 127.0.0.1, for a proxy on this machine. 0.0.0.0 opens both to the network, without TLS.
CONTROL_PORT, ORBSEARCH_PORTThe host ports of the panel (default 8000) and the Query API (default 7800). Change APP_URL with CONTROL_PORT.
POSTGRES_PORT, MEILISEARCH_PORTThe host ports of Postgres (default 5432) and Meilisearch (default 7700). Both always listen on 127.0.0.1 only.
TRUSTED_PROXIESThe addresses of the proxies in front of the panel, comma-separated. Never *: the control plane refuses to run with it.
MAIL_*A mailer for the panel: MAIL_MAILER (such as smtp), MAIL_SCHEME, MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD and MAIL_FROM_ADDRESS. A real mailer turns on email verification; without one, mail goes to the log.
ORBSEARCH_FEEDS_FROM_PRIVATE_NETWORKSOptional. true lets the feed URL be on a private, loopback or link-local network, such as the shop's own; by default only public addresses are fetched. make stack sets it for the flows' local feed.
AI_GATEWAY_API_KEYOptional. A Vercel AI Gateway key turns on column suggestions in the import panel. Without it, the import maps with its own rules only.
DEMO_PORTThe port of the demo storefront that make demo and make e2e serve on the host. Not part of the stack.

The settings from APP_KEY to ORBSEARCH_MANAGEMENT_KEY are required: Compose refuses to start while one of them is empty. The database passwords are set in Postgres when its volume is first initialized, so changing them in .env later does not change them in the database.

TLS

OrbSearch speaks plain HTTP and leaves TLS to a proxy on the same machine, such as Caddy or nginx. With the default PUBLISH_ADDRESS, nothing but that proxy reaches the stack.

Public address, for exampleForward toServes
https://search.example.com127.0.0.1:7800The Query API, for storefronts and browsers
https://panel.example.com127.0.0.1:8000The panel and the management API

Then set APP_URL to the panel's https:// address and TRUSTED_PROXIES to the address the proxy connects from, and run docker compose up --detach --wait. Postgres, Meilisearch and the internal port stay off the network.

Security

  • Create the owner account first. The first visitor of the panel becomes its owner, so create the account before the proxy forwards to it. Until then, with the default PUBLISH_ADDRESS, only the machine itself reaches the panel; from your own computer, ssh -L 8000:127.0.0.1:8000 <server> opens it at http://localhost:8000.
  • The Query API has no key and no rate limit. It answers whoever reaches it, as a storefront needs. Limit request rates at the proxy. A short cache of GET answers there takes load off too, at the price of a new release or a sale window showing that much later.
  • Each part holds only the engine key it needs. The indexer holds Meilisearch's master key and creates the Query API's key from it at start: a key that may only search and list indexes, so a Query API that is broken into cannot change or delete the engine's indexes, rules or keys. The control plane holds none. After a new MEILI_MASTER_KEY, remove MEILI_SEARCH_KEY from .env and run make -B .env to derive the matching key; by hand it is printf %s 5e7d2c4a-8f1b-4c3e-9a6d-0b2f4e6a8c1d | openssl dgst -sha256 -hmac "$MEILI_MASTER_KEY".
  • The management API allows no browser origin. It sends no CORS headers, since only scripts, agents and the panel on its own origin call it.
  • Rotate the management key by giving ORBSEARCH_MANAGEMENT_KEY in .env a new value and recreating control, the one service that reads it. From then on the old key answers 401, and $key holds the new one:
Terminal
key=$(openssl rand -hex 32)
sed -i.bak "s/^ORBSEARCH_MANAGEMENT_KEY=.*/ORBSEARCH_MANAGEMENT_KEY=$key/" .env && rm .env.bak
docker compose up --detach --wait control

Where data lives

WhereWhatBack it up
postgres volumeThe orbsearch database: the owner's account, every release with its files, imports, index runs with their reports, the record of each snapshot, and the search log with its events and daily rollupsYes
storage volumeThe kept exports as they were uploaded, byte for byte, as snapshots/<id>. The control plane writes them; the indexer reads and removes themYes
.envThe settings and secretsYes, somewhere safe
meilisearch volumeThe indexes index runs compiled: derived data onlyNo

The catalog is never stored in Postgres tables. It lives as snapshots, files that never change once written, and every index run compiles one of them with a release. That is why Meilisearch holds nothing you could lose: one index run rebuilds it from the current catalog and the published release.

What is kept

A shop that sends a 2 GB export every day would add about 60 GB a month, so the indexer cleans up once it starts and after every index run that goes live. It keeps the snapshot files:

  • of the live run, of every run still queued or running, of every draft import, and of an import that failed since the live run went live, so it can be corrected and published again;
  • of the current catalog, which publishing a release and rebuilding index;
  • of the newest other snapshots that went live, five unless you keep more or fewer under Settings → Advanced in the panel, or with PATCH /api/v1/storage and { "kept_snapshots": 2 }.

Every other file goes, including those of discarded imports, whose export then carries removed_at too, a file nothing names, as an upload whose transaction failed leaves, once it is a day old, and, once a day old, what an upload that died halfway left. Its row stays with the time it was removed, and so does the history of its index runs and their reports. A rollback publishes an earlier release with the current catalog, so it never needs an old snapshot; an import whose export is gone goes live again once the same export is sent again.

The indexer also drops the catalog dictionary and the record of every run that is not live, which only the Query API and the next publish read with the live run (a run that keeps the live indexes carries both on), and deletes the engine indexes no run uses: those of a run that failed or was superseded, and those left when an indexer stopped between swapping a run live and recording it. It never touches an index OrbSearch did not make. It deletes the engine rules that neither the live release nor the one live before it names, so a Query API that has not switched yet still finds the rules of its release. Afterwards it measures the engine's size on disk.

First it checks the engine against Postgres: when the engine swapped in a newer run than the live one, an indexer stopped between the swap and recording it, and the live indexes hold documents no live run describes. The live run is then rebuilt within minutes.

The panel's Releases page shows in one line what the kept snapshots and the engine take, and mutes the catalog of a run whose file is gone. GET /api/v1/storage answers the same, and every snapshot in the management API carries removed_at once its file is removed:

Terminal
curl -s -H "Authorization: Bearer $key" $api/storage
{
    "kept_snapshots": 5,
    "snapshots": { "count": 7, "bytes": 1288490188 },
    "engine": { "bytes": 356515840, "measured_at": "2026-10-08T07:24:33.157+00:00" }
}

A fresh stack's indexer waits for the control plane's migrations before its first clean-up.

The search log keeps every answered search, and every click and view storefronts report, 60 days, in one partition per UTC day that the control plane drops when it is older; the daily rollups Insights reads stay.

Backups

Back up the database, the snapshots and .env, together.

Terminal
docker compose stop indexer
docker compose exec --no-TTY postgres pg_dump --username=postgres --format=custom orbsearch > orbsearch.dump
docker compose cp control:/var/lib/orbsearch/snapshots/. ./snapshots
docker compose start indexer

The indexer pauses while the database is dumped and the snapshots are copied out of the storage volume, so its clean-up cannot remove a file the dump still names; searches go on meanwhile, and runs queued in the pause start once it is back. In this order, every kept snapshot the dump names is in the copy. Snapshots never change once written, so a backup of them only needs to add new files and may drop those the indexer removed.

Leave Meilisearch out. If its volume is lost, the Query API answers 503 until the next index run; queue one with POST /api/v1/index-runs, or with Rebuild index on the panel's Releases screen.

Restore

Restore into a stack whose volumes are new, from a checkout of the version you backed up, with the backed-up .env in it: its APP_KEY decrypts the panel's two-factor secrets, and its database passwords become those of the new roles.

Terminal
docker compose up --detach --wait postgres
docker compose exec --no-TTY postgres pg_restore --username=postgres --dbname=orbsearch < orbsearch.dump
docker compose up --build --detach --wait control
docker compose cp ./snapshots/. control:/var/lib/orbsearch/snapshots
docker compose exec --user root control chown -R www-data:www-data /var/lib/orbsearch/snapshots
docker compose up --build --detach --wait

Postgres starts alone first, creating the roles and an empty orbsearch database, and takes the dump before the control plane migrates anything. The control plane then starts on the restored database and gets the snapshots back, owned by the user it writes as. Last come the indexer, the Query API and an empty Meilisearch, which holds only derived data: one index run rebuilds it, with $key and $api from the Quickstart or Rebuild index on the panel's Releases screen.

Terminal
curl -s -X POST -H "Authorization: Bearer $key" $api/index-runs

Move to a new machine

Back up the old machine, restore on the new one, and point your proxy or DNS at it once it searches. Release and snapshot ids stay the same, since they are named by their content. Stop the old stack with docker compose down, and keep its volumes until you are sure you will not go back.

Health and logs

docker compose ps shows each service's health: the Query API and the indexer answer GET /health on port 7800, the control plane GET /up on port 8000, Postgres pg_isready and Meilisearch its own GET /health.

Every service logs to its container's output, so docker compose logs --follow indexer shows what the indexer does. The Query API and the indexer log at the info level; the control plane logs to stderr. A failed index run also says why in its error, in the management API and on the panel's Releases screen.

Upgrades

Back up

Take the backups above first.

Update the checkout and the settings

Check out the new version of the repository, then run make .env, which adds any setting the new .env.example brings and keeps your values.

Rebuild and restart

Terminal
docker compose up --build --detach --wait

The control plane migrates the database before it serves a request; if a migration fails, it does not start and says why in its log. The Query API lets open requests finish before it stops, and an index run the restart interrupted is taken up again.

When the new version compiles documents differently, the indexer rebuilds the live run on its own; the shop answers from the old indexes meanwhile. Its log names the build at start, such as build="916874f12879", and a run's course says indexer_changed with both builds.

The engine is pinned: compose.yaml names an exact Meilisearch image, raised only after the contract tests in tests/meilisearch-contract pass on the new version. They check the engine behavior OrbSearch relies on, such as rules switched on per request, exact facet counts and how words are split. If you change the image yourself, run them first with make meilisearch-contract, which needs Docker and the development toolchain from the README. An engine that starts without its indexes loses nothing: one rebuild brings search back.

On this page