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.
| Service | Image | Listens on the host | Volume | Job |
|---|---|---|---|---|
orbsearch | built from crates/orbsearch/Dockerfile | 127.0.0.1:7800 | storage, read-only | The Query API (orbsearch serve), which storefronts call |
indexer | the same image | - | storage, read-write | Index runs (orbsearch index): compiles a catalog snapshot and a release into Meilisearch and swaps them live |
control | built from apps/control/Dockerfile | 127.0.0.1:8000 | storage, read-write | The control plane: the panel, the management API under /api/v1, and the schedule that fetches the feed URL |
postgres | postgres:18.6-trixie | 127.0.0.1:5432 | postgres | Accounts, releases, imports and index runs |
meilisearch | getmeili/meilisearch:v1.54.3 | 127.0.0.1:7700 | meilisearch | The 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
make .envThis 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
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.
| Setting | What it does |
|---|---|
APP_KEY | The 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_URL | Where merchants open the panel. Default: http://localhost:8000. An https:// address also makes the panel's session cookies secure. |
POSTGRES_PASSWORD | The Postgres superuser, which only sets up the roles. |
DB_PASSWORD | The control plane's role, control. |
INDEXER_DB_PASSWORD | The indexer's role, indexer. |
QUERY_DB_PASSWORD | The Query API's role, query. |
MEILI_MASTER_KEY | Meilisearch's master key, at least 16 bytes. Only the indexer holds it. |
MEILI_SEARCH_KEY | The 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_KEY | The key of the management API, sent as Authorization: Bearer <key>. |
PUBLISH_ADDRESS | The 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_PORT | The host ports of the panel (default 8000) and the Query API (default 7800). Change APP_URL with CONTROL_PORT. |
POSTGRES_PORT, MEILISEARCH_PORT | The host ports of Postgres (default 5432) and Meilisearch (default 7700). Both always listen on 127.0.0.1 only. |
TRUSTED_PROXIES | The 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_NETWORKS | Optional. 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_KEY | Optional. A Vercel AI Gateway key turns on column suggestions in the import panel. Without it, the import maps with its own rules only. |
DEMO_PORT | The 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 example | Forward to | Serves |
|---|---|---|
https://search.example.com | 127.0.0.1:7800 | The Query API, for storefronts and browsers |
https://panel.example.com | 127.0.0.1:8000 | The 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 athttp://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
GETanswers 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, removeMEILI_SEARCH_KEYfrom.envand runmake -B .envto derive the matching key; by hand it isprintf %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_KEYin.enva new value and recreatingcontrol, the one service that reads it. From then on the old key answers 401, and$keyholds the new one:
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 controlWhere data lives
| Where | What | Back it up |
|---|---|---|
postgres volume | The 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 rollups | Yes |
storage volume | The kept exports as they were uploaded, byte for byte, as snapshots/<id>. The control plane writes them; the indexer reads and removes them | Yes |
.env | The settings and secrets | Yes, somewhere safe |
meilisearch volume | The indexes index runs compiled: derived data only | No |
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/storageand{ "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:
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.
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 indexerThe 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.
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 --waitPostgres 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.
curl -s -X POST -H "Authorization: Bearer $key" $api/index-runsMove 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
docker compose up --build --detach --waitThe 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.