# Can I self-host Algolia?

**YES** — it's called Meilisearch. ONE COMMAND setup · ~10 minutes to running · 1 GB RAM minimum.

Meilisearch authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/algolia/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Meilisearch 1.52.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say two things first. One: this is a search API, not a website with accounts. There is no
browser sign-in to finish and no admin dashboard to claim. Two: without a master key the
instance is not gated the way a public hostname requires, so step 3 generates that key before
the container starts.

Meilisearch needs 1024 MB of RAM available and 10 GB free on /srv. The image publishes amd64
and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve. The 1 GB floor is for a small personal index; multi-million
document catalogues need more RAM.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/meilisearch /srv/meilisearch/backups /srv/meilisearch/data
ls -la /srv/meilisearch
```

Assert: the directories exist and are owned by the login user. Nothing is written outside
/srv/meilisearch. Index files will appear under `data/` after the first documents are added.

## 3. Secrets

One secret: the master key. Generate it on the server. Do not print it, do not repeat it in
your summary, and do not put it in any log line. Upstream requires at least 16 bytes of valid
UTF-8; hex 32 is more than enough.

```bash
umask 077
cat > /srv/meilisearch/.env <<EOF
MEILI_MASTER_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/meilisearch/.env
umask 022
ls -la /srv/meilisearch/.env
```

Assert: `.env` is mode 600. Print only the path, never the key. Tell the user the key lives in
/srv/meilisearch/.env and they can read it later with
`sudo grep MEILI_MASTER_KEY /srv/meilisearch/.env`. It belongs in their password manager if
they will hand it to an application later. Never put the master key in a browser, a public
frontend, or a chat transcript. Step 7 creates a search-scoped key for client use.

## 4. compose.yml

```bash
cat > /srv/meilisearch/compose.yml <<'EOF'
# Meilisearch · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker .............. https://www.meilisearch.com/docs/guides/misc/docker
#   security ............ https://www.meilisearch.com/docs/learn/security/basic_security
#   configuration ....... https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference
#   license ............. https://github.com/meilisearch/meilisearch/blob/v1.52.0/LICENSE
#
# One container. MEILI_MASTER_KEY comes from env_file (generated on the server).
# MEILI_NO_ANALYTICS opts out of telemetry. Indexes live under /meili_data.
# Tag and digest are the v1.52.0 release read from Docker Hub on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.52.0@sha256:d36e713e8f89483af1ab0d72011bbd503f5ab100b68ccbfad51c39e3f0a0567d
    container_name: meilisearch
    restart: unless-stopped
    env_file: /srv/meilisearch/.env
    environment:
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - /srv/meilisearch/data:/meili_data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8203.
      - "127.0.0.1:8203:7700"
EOF
cd /srv/meilisearch && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no database container:
indexes live under `data/`. `MEILI_ENV=production` and `MEILI_NO_ANALYTICS=true` are set in
the file; the master key stays only in `.env`. Do not add a Caddy service to this file.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
cat > /srv/meilisearch/Caddyfile <<'EOF'
# Meilisearch · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.meilisearch.com/docs/learn/security/basic_security and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project. Auth is the
# master key inside Meilisearch, not a browser login form.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8203 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8203
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-meilisearch
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${REAL_DOMAIN}|g" /srv/meilisearch/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Set `REAL_DOMAIN` to the hostname the user gave in step 1 before running sed. Do not wrap the
value in extra quotes inside the sed replacement. Assert: validate and reload exit 0. If
validate fails, restore `/etc/caddy/Caddyfile.before-meilisearch`, reload, and report what it
objected to. Caddy requests the certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8203 stays closed because compose binds it to 127.0.0.1. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
mentioning 8203 or 7700.

## 7. Start and verify

```bash
cd /srv/meilisearch
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8203/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://127.0.0.1:8203/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/indexes
MASTER=$(grep MEILI_MASTER_KEY /srv/meilisearch/.env | cut -d= -f2-)
curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer ${MASTER}" https://<DOMAIN>/indexes
curl -sS -H "Authorization: Bearer ${MASTER}" https://<DOMAIN>/keys
```

Assert all five, and print the HTTP codes (not the master key). The health loop ends on `200`
and the body is a health payload. The unauthenticated call to `/indexes` prints `401`: that is
the security assert in this block. The authenticated `/indexes` call prints `200`. The `/keys`
call returns JSON that includes default API keys, among them a search key whose actions include
`search`. If any assert misses, stop, run `docker compose logs --tail 40 meilisearch`, and name
the likely earlier step: a container that exits on a missing master key is step 3, and a 502
from Caddy with a running container is step 5. Unset `MASTER` when you are done reading keys:
`unset MASTER`. A running container is not success.

There is no sign-in page. Opening https://<DOMAIN>/ in a browser shows the API root JSON, not
an account form. The product is HTTP with `Authorization: Bearer …` headers.

STOP: tell the user to read the master key with
`sudo grep MEILI_MASTER_KEY /srv/meilisearch/.env`, store it offline, and create a
search-scoped key for any browser or public client by calling `GET /keys` (or creating one with
`POST /keys`) using only the master key on the server. Do not continue until they confirm they
have the master key stored and understand the search key is what frontends receive. Never ship
the master key to a browser.

A minimal handoff once they confirm, still on the server, without printing secrets into chat:

```bash
MASTER=$(grep MEILI_MASTER_KEY /srv/meilisearch/.env | cut -d= -f2-)
curl -sS -X POST https://<DOMAIN>/indexes \
  -H "Authorization: Bearer ${MASTER}" \
  -H 'Content-Type: application/json' \
  --data-binary '{"uid":"movies","primaryKey":"id"}'
curl -sS -X POST 'https://<DOMAIN>/indexes/movies/documents' \
  -H "Authorization: Bearer ${MASTER}" \
  -H 'Content-Type: application/json' \
  --data-binary '[{"id":1,"title":"Carol"},{"id":2,"title":"Wonder Woman"}]'
unset MASTER
```

Assert: both calls return task JSON (or index metadata) without a 401. Documents index
asynchronously; a search a few seconds later with a search API key should find `Carol`.

## 8. First backup and restore

One archive: the index data, the master key in `.env`, compose, and the live Caddy site block.

```bash
cd /srv/meilisearch
docker compose stop
sudo tar -czf /srv/meilisearch/backups/meilisearch-$(date +%F).tar.gz -C /srv/meilisearch data .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/meilisearch/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds;
the container is stopped so index files are not copied mid-write. Treat the archive as secret
material: it holds the master key.

A backup on the same disk as the data is not a backup. Run this from the user's machine, not
the server:

```bash
mkdir -p ~/backups/meilisearch
scp vps:/srv/meilisearch/backups/*.tar.gz ~/backups/meilisearch/
```

To restore: `docker compose down`, `sudo rm -rf /srv/meilisearch/data`, recreate `data` as in
step 2, untar the archive back into /srv/meilisearch (restores `data/`, `.env`, compose.yml),
put the Caddy block back if that is what was lost, then `docker compose up -d`. Tell the user
the honest split: `data/` is every document they indexed, `.env` is the master key that gates
the API, and restoring data without the key leaves a locked instance. Losing the key without a
copy means minting a new master key and accepting that old keys no longer match that instance
policy.

## 9. Updating later

New versions are listed at https://github.com/meilisearch/meilisearch/releases. The release tag
and the image tag are the same string, so release `v1.53.0` is image tag `v1.53.0`. Take a
backup first, then edit the image line in /srv/meilisearch/compose.yml to the new tag and its
digest:

```bash
cd /srv/meilisearch
docker compose pull
docker compose up -d
docker compose logs --tail 30 meilisearch
```

Watch that log until it settles, then re-run step 7's health and unauthenticated 401 checks
before calling the update done.

## 10. What will probably go wrong

You will paste the master key into a frontend "API key" field because it is the only string in
`.env` and it works in curl. I did that once on a demo page. Every visitor then held a key that
could delete indexes. Upstream is explicit: use the master key only to manage keys, and hand
browsers a search-scoped key from `/keys`. If you already leaked the master key, rotate by
stopping the container, generating a new `MEILI_MASTER_KEY` in `.env`, starting again, and
re-issuing every application key. Indexes survive; the old master key does not.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy is already running under systemd on
  this box, and a second one would fight it for 80 and 443.
- Do not publish 7700 or 8203 on the public interface or open them in the firewall. Caddy is
  the only way in.
- Do not leave `MEILI_MASTER_KEY` empty and do not run without `MEILI_ENV=production` on a
  public hostname.
- Do not enable Enterprise Edition features that require a commercial agreement. This install
  is the MIT-covered search API path of the dual-licensed tree.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Meilisearch 1.52.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read these before step 1. This is a search API, not a website with accounts: there is no browser
sign-in to finish and no admin dashboard to claim. Without a master key the instance is not gated
the way a public hostname requires, so you will generate that key before the container starts.
Never put the master key in a browser; frontends get a search-scoped key from /keys. The tree at
the pinned tag is dual-licensed MIT AND BUSL-1.1: this install uses the MIT search API path, not
Enterprise Edition production features that need a commercial agreement.

## 1. Preflight

Its A record must already point at this server.

Meilisearch needs 1024 MB of RAM available and 10 GB free on /srv. The image publishes amd64
and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve. The 1 GB floor is for a small personal index; multi-million
document catalogues need more RAM. A search engine without an application in front of it is an
empty box: this install starts the engine, and you still write the code that posts documents
and runs queries.


## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/meilisearch /srv/meilisearch/backups /srv/meilisearch/data
ls -la /srv/meilisearch
```

You should see `backups` and `data` owned by your login user. Nothing is written outside
/srv/meilisearch. Index files appear under `data/` after the first documents are added. Until
then the directory stays almost empty, which is normal.


## 3. Secrets

One secret: the master key. Generate it on the server. Do not paste the value into chat or into a public page. Upstream requires at least 16 bytes of valid
UTF-8; hex 32 is more than enough.

```bash
umask 077
cat > /srv/meilisearch/.env <<EOF
MEILI_MASTER_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/meilisearch/.env
umask 022
ls -la /srv/meilisearch/.env
```

Assert: `.env` is mode 600. Print only the path, never the key. Read it later with
`sudo grep MEILI_MASTER_KEY /srv/meilisearch/.env` and store it offline. Never put the master
key in a browser or public frontend. Step 7 creates a search-scoped key for client use.

## 4. compose.yml

```bash
cat > /srv/meilisearch/compose.yml <<'EOF'
# Meilisearch · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker .............. https://www.meilisearch.com/docs/guides/misc/docker
#   security ............ https://www.meilisearch.com/docs/learn/security/basic_security
#   configuration ....... https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference
#   license ............. https://github.com/meilisearch/meilisearch/blob/v1.52.0/LICENSE
#
# One container. MEILI_MASTER_KEY comes from env_file (generated on the server).
# MEILI_NO_ANALYTICS opts out of telemetry. Indexes live under /meili_data.
# Tag and digest are the v1.52.0 release read from Docker Hub on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.52.0@sha256:d36e713e8f89483af1ab0d72011bbd503f5ab100b68ccbfad51c39e3f0a0567d
    container_name: meilisearch
    restart: unless-stopped
    env_file: /srv/meilisearch/.env
    environment:
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - /srv/meilisearch/data:/meili_data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8203.
      - "127.0.0.1:8203:7700"
EOF
cd /srv/meilisearch && docker compose config >/dev/null && echo "compose OK"
```

You should see `compose OK`. One service, one published port, no database container: indexes
live under `data/`. `MEILI_ENV=production` and `MEILI_NO_ANALYTICS=true` are set in the file;
the master key stays only in `.env`. Do not add a Caddy service to this file. Telemetry is off
on purpose so anonymous usage stats do not leave this box.


## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
cat > /srv/meilisearch/Caddyfile <<'EOF'
# Meilisearch · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.meilisearch.com/docs/learn/security/basic_security and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project. Auth is the
# master key inside Meilisearch, not a browser login form.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8203 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8203
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-meilisearch
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${REAL_DOMAIN}|g" /srv/meilisearch/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Set `REAL_DOMAIN` to your real hostname before running sed (example:
`REAL_DOMAIN=search.example.com`). Do not wrap the value in extra quotes inside the sed
replacement. Validate and reload should exit 0. If validate fails, restore
`/etc/caddy/Caddyfile.before-meilisearch`, reload, and read the error. Caddy requests the
certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8203 stays closed because compose binds it to 127.0.0.1. You should see
`Status: active`, rules for 80 and 443, and no rule mentioning 8203 or 7700. Opening 7700 would
put the API on the public internet without Caddy's TLS path.


## 7. Start and verify

```bash
cd /srv/meilisearch
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8203/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://127.0.0.1:8203/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/indexes
MASTER=$(grep MEILI_MASTER_KEY /srv/meilisearch/.env | cut -d= -f2-)
curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer ${MASTER}" https://<DOMAIN>/indexes
curl -sS -H "Authorization: Bearer ${MASTER}" https://<DOMAIN>/keys
```

You should see: health loop ends on `200`; unauthenticated `/indexes` is `401` (the security
assert); authenticated `/indexes` is `200`; `/keys` lists default keys including a search key.
Print HTTP codes, not the master key. If any miss, run `docker compose logs --tail 40
meilisearch`. Unset `MASTER` when done: `unset MASTER`.

There is no sign-in page. Opening https://<DOMAIN>/ shows API JSON, not an account form.

STOP: read the master key with `sudo grep MEILI_MASTER_KEY /srv/meilisearch/.env`, store it
offline, and plan to use only a search-scoped key from `/keys` in any browser. Do not continue until they confirm they have the master key stored and understand the search key is what frontends receive.

Sample handoff once stored (do not paste secret values into chat):

```bash
MASTER=$(grep MEILI_MASTER_KEY /srv/meilisearch/.env | cut -d= -f2-)
curl -sS -X POST https://<DOMAIN>/indexes \
  -H "Authorization: Bearer ${MASTER}" \
  -H 'Content-Type: application/json' \
  --data-binary '{"uid":"movies","primaryKey":"id"}'
curl -sS -X POST 'https://<DOMAIN>/indexes/movies/documents' \
  -H "Authorization: Bearer ${MASTER}" \
  -H 'Content-Type: application/json' \
  --data-binary '[{"id":1,"title":"Carol"},{"id":2,"title":"Wonder Woman"}]'
unset MASTER
```

Both calls should return task JSON without a 401. Documents index asynchronously; a search a
few seconds later with a search API key should find `Carol`.

## 8. First backup and restore

One archive: the index data, the master key in `.env`, compose, and the live Caddy site block.

```bash
cd /srv/meilisearch
docker compose stop
sudo tar -czf /srv/meilisearch/backups/meilisearch-$(date +%F).tar.gz -C /srv/meilisearch data .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/meilisearch/backups/
```

The archive should exist and be non-empty. Print its size. Downtime is about five seconds.
Treat the archive as secret material: it holds the master key.

A backup on the same disk is not a backup. From your own machine:

```bash
mkdir -p ~/backups/meilisearch
scp vps:/srv/meilisearch/backups/*.tar.gz ~/backups/meilisearch/
```

To restore: `docker compose down`, remove `data`, recreate it, untar into /srv/meilisearch,
put the Caddy block back if needed, then `docker compose up -d`. `data/` is every document you
indexed; `.env` is the master key. They travel together or the API stays locked.



After the first real application is wired, run one deliberate search and one deliberate delete
of a test index so you know which key can do which. A search key that unexpectedly returns 403
on a write is correct behaviour. An admin key in a browser is the failure mode step 10 describes.

On restore after disk loss: bring Docker and Caddy back (Prompt Zero), restore the tar into
/srv/meilisearch, restore the Caddyfile, `docker compose up -d`, then prove unauthenticated
calls still return 401 and authenticated /health or /indexes still return 200 with the restored
master key. If you only restore `data/` and regenerate `.env`, the instance starts but old
application keys and assumptions about the previous master key no longer hold.

## 9. Updating later

New versions are listed at https://github.com/meilisearch/meilisearch/releases. The release tag
and the image tag are the same string, so release `v1.53.0` is image tag `v1.53.0`. Take a
backup first, then edit the image line in /srv/meilisearch/compose.yml to the new tag and its
digest:

```bash
cd /srv/meilisearch
docker compose pull
docker compose up -d
docker compose logs --tail 30 meilisearch
```

Watch that log until it settles, then re-run step 7's health and unauthenticated 401 checks
before calling the update done. If a release notes page mentions a one-way dump or reindex,
read it before pulling: index formats can force a rebuild after major jumps.


## 10. What will probably go wrong

You will paste the master key into a frontend "API key" field because it is the only string in
`.env` and it works in curl. I did that once on a demo page. Every visitor then held a key that
could delete indexes. Upstream is explicit: use the master key only to manage keys, and hand
browsers a search-scoped key from `/keys`. If you already leaked the master key, rotate by
stopping the container, generating a new `MEILI_MASTER_KEY` in `.env`, starting again, and
re-issuing every application key. Indexes survive; the old master key does not.

The other miss is disk. A bulk import of a large crawl without checking free space fills
`/srv/meilisearch/data` until the container cannot write. Watch `df -h /srv` during the first
real index job, and keep a second disk plan for anything that grows like a product catalogue.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy is already running under systemd on
  this box, and a second one would fight it for 80 and 443.
- Do not publish 7700 or 8203 on the public interface or open them in the firewall. Caddy is
  the only way in.
- Do not leave `MEILI_MASTER_KEY` empty and do not run without `MEILI_ENV=production` on a
  public hostname.
- Do not skip the first backup after indexes hold real data.
- Do not enable Enterprise Edition features that require a commercial agreement. This install
  is the MIT-covered search API path of the dual-licensed tree.

SPDX at the pin is MIT AND BUSL-1.1 in one repository. Community Edition search is MIT.
Enterprise Edition paths (sharding, S3-streaming snapshots and related EE work) are BUSL-1.1
and are not free to run in production without a commercial agreement with Meilisearch. Source-
available candor applies to the BUSL part: it is not OSI open source for those paths.

When you integrate with an app, prefer the default search API key listed by GET /keys for any
code that runs in a browser, and keep the default admin API key on the server the same way you
keep the master key. Upstream warns not to expose admin keys on a public frontend.

Keep one off-box copy of the backup whenever indexes hold data you cannot re-crawl cheaply.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Meilisearch 1.52.0 under ~/selfhost/meilisearch, answering at http://localhost:8203.

## 1. Preflight

Say this to the user before anything installs. This is a search API on this computer only:
http://localhost:8203 is unreachable from a phone or another laptop. What they get is an
engine their local app can index and query while they develop, not a shared production API.
There is no browser sign-in to finish.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Meilisearch needs 1024 MB of RAM available
and 10 GB free on the home disk, and the image publishes amd64 and arm64. Every branch prints
free memory; on macOS and Windows Docker Desktop takes its allocation out of the host figure.
If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/meilisearch/data ~/selfhost/meilisearch/backups
ls -la ~/selfhost/meilisearch
```

Assert: `data` and `backups` exist. Index files land under `data/` after documents are added.

## 4. Secrets

One secret: the master key. Generate it here. Do not print it into chat.

```bash
umask 077
cat > ~/selfhost/meilisearch/.env <<EOF
MEILI_MASTER_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/meilisearch/.env
umask 022
ls -l ~/selfhost/meilisearch/.env
```

Assert: mode `-rw-------` (advisory on Windows NTFS). Tell the user to read it with
`grep MEILI_MASTER_KEY ~/selfhost/meilisearch/.env` when they wire an app. Never put the
master key in a browser; use a search-scoped key from `/keys` for frontends.

## 5. compose.yml

```bash
cat > ~/selfhost/meilisearch/compose.yml <<'EOF'
# Meilisearch · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker .............. https://www.meilisearch.com/docs/guides/misc/docker
#   security ............ https://www.meilisearch.com/docs/learn/security/basic_security
#   configuration ....... https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference
#   license ............. https://github.com/meilisearch/meilisearch/blob/v1.52.0/LICENSE
#
# One container on the computer you are sitting at. Paths are relative to
# ~/selfhost/meilisearch/. MEILI_MASTER_KEY comes from ./.env. Tag and digest
# are the v1.52.0 release read from Docker Hub on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.52.0@sha256:d36e713e8f89483af1ab0d72011bbd503f5ab100b68ccbfad51c39e3f0a0567d
    container_name: meilisearch
    restart: unless-stopped
    env_file: ./.env
    environment:
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - ./data:/meili_data
    ports:
      # Loopback only: no other device on the wifi can reach 8203.
      - "127.0.0.1:8203:7700"
EOF
cd ~/selfhost/meilisearch && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Firewall

Nothing to open. Confirm loopback binding:

```bash
grep -c '"127.0.0.1:' ~/selfhost/meilisearch/compose.yml
```

Assert: that prints `1`. Do not rebind to `0.0.0.0`.

## 7. Start and verify

```bash
cd ~/selfhost/meilisearch
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8203/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8203/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8203/indexes
MASTER=$(grep MEILI_MASTER_KEY ~/selfhost/meilisearch/.env | cut -d= -f2-)
curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer ${MASTER}" http://localhost:8203/indexes
curl -sS -H "Authorization: Bearer ${MASTER}" http://localhost:8203/keys | head -c 400; echo
unset MASTER
```

Assert: health is 200; unauthenticated `/indexes` is `401`; authenticated `/indexes` is `200`;
`/keys` returns JSON listing default keys. Print the codes, not the master key. If the 401 is
missing, the master key did not load: check `.env` and recreate it from step 4.

STOP: tell the user there is no sign-in UI, that apps talk HTTP with Bearer tokens, and that
they should store the master key offline and create a search key via `/keys` for any browser
code. Do not continue until they confirm.

Sample index handoff:

```bash
MASTER=$(grep MEILI_MASTER_KEY ~/selfhost/meilisearch/.env | cut -d= -f2-)
curl -sS -X POST http://localhost:8203/indexes \
  -H "Authorization: Bearer ${MASTER}" \
  -H 'Content-Type: application/json' \
  --data-binary '{"uid":"movies","primaryKey":"id"}'
curl -sS -X POST 'http://localhost:8203/indexes/movies/documents' \
  -H "Authorization: Bearer ${MASTER}" \
  -H 'Content-Type: application/json' \
  --data-binary '[{"id":1,"title":"Carol"},{"id":2,"title":"Wonder Woman"}]'
unset MASTER
```

## 8. First backup and restore

```bash
cd ~/selfhost/meilisearch
docker compose stop
tar -C ~/selfhost/meilisearch -czf ~/selfhost/meilisearch/backups/meilisearch-$(date +%F).tar.gz data .env compose.yml
docker compose start
ls -lh ~/selfhost/meilisearch/backups/
```

Assert: the archive exists and is non-empty. Print its size. It holds the master key; treat it
as secret. Ask the user for a destination that leaves this computer and copy it with `cp`.

To restore: `docker compose down`, remove `data`, untar into ~/selfhost/meilisearch, then
`docker compose up -d`. `data/` is the indexes; `.env` is the master key; they travel together.

## 9. Updating later

New versions are at https://github.com/meilisearch/meilisearch/releases. Take a backup first,
then edit the image line in ~/selfhost/meilisearch/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/meilisearch
docker compose pull
docker compose up -d
docker compose logs --tail 30 meilisearch
```

Re-run step 7's health and 401 checks before calling the update done.

## 10. What will probably go wrong

You will wire the master key into a React demo because it is the only credential you have and
search starts working. Anyone who opens the page then holds a key that can wipe indexes.
Create a search-scoped key from `/keys` for the browser and keep the master key on the server
side only. If you already pasted it into client code, rotate the master key in `.env` and
restart.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8203 to 0.0.0.0.
- Do not leave `MEILI_MASTER_KEY` empty.
````

## docker-compose.yml

```yaml
# Meilisearch · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker .............. https://www.meilisearch.com/docs/guides/misc/docker
#   security ............ https://www.meilisearch.com/docs/learn/security/basic_security
#   configuration ....... https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference
#   license ............. https://github.com/meilisearch/meilisearch/blob/v1.52.0/LICENSE
#
# One container. MEILI_MASTER_KEY comes from env_file (generated on the server).
# MEILI_NO_ANALYTICS opts out of telemetry. Indexes live under /meili_data.
# Tag and digest are the v1.52.0 release read from Docker Hub on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.52.0@sha256:d36e713e8f89483af1ab0d72011bbd503f5ab100b68ccbfad51c39e3f0a0567d
    container_name: meilisearch
    restart: unless-stopped
    env_file: /srv/meilisearch/.env
    environment:
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - /srv/meilisearch/data:/meili_data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8203.
      - "127.0.0.1:8203:7700"
```

## compose.local.yml

```yaml
# Meilisearch · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker .............. https://www.meilisearch.com/docs/guides/misc/docker
#   security ............ https://www.meilisearch.com/docs/learn/security/basic_security
#   configuration ....... https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference
#   license ............. https://github.com/meilisearch/meilisearch/blob/v1.52.0/LICENSE
#
# One container on the computer you are sitting at. Paths are relative to
# ~/selfhost/meilisearch/. MEILI_MASTER_KEY comes from ./.env. Tag and digest
# are the v1.52.0 release read from Docker Hub on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.52.0@sha256:d36e713e8f89483af1ab0d72011bbd503f5ab100b68ccbfad51c39e3f0a0567d
    container_name: meilisearch
    restart: unless-stopped
    env_file: ./.env
    environment:
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - ./data:/meili_data
    ports:
      # Loopback only: no other device on the wifi can reach 8203.
      - "127.0.0.1:8203:7700"
```

## Caddyfile

```text
# Meilisearch · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.meilisearch.com/docs/learn/security/basic_security and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project. Auth is the
# master key inside Meilisearch, not a browser login form.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8203 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8203
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Meilisearch · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=search.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.meilisearch.com/docs/guides/misc/docker
#   https://www.meilisearch.com/docs/learn/security/basic_security
#   https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference
#   https://github.com/meilisearch/meilisearch/blob/v1.52.0/LICENSE
#
# One secret is generated here: MEILI_MASTER_KEY. It goes into
# /srv/meilisearch/.env with mode 600 and is never printed. There is no browser
# sign-in. Unauthenticated API calls must return 401 once the key is set.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/meilisearch}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. search.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" \
	"$APP_DIR" "$APP_DIR/backups" "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Master key on the server ---------------------------------------------

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-EOF
		MEILI_MASTER_KEY=$(openssl rand -hex 32)
	EOF
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block -----------------------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-meilisearch"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Firewall -------------------------------------------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8203 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start and assert -----------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for http://127.0.0.1:8203/health"
for _ in $(seq 1 24); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8203/health" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 meilisearch"

unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/indexes" || true)"
[ "$unauth" = "401" ] || die "unauthenticated /indexes returned ${unauth}, not 401. Stop and investigate."

MASTER="$(grep MEILI_MASTER_KEY "$APP_DIR/.env" | cut -d= -f2-)"
auth="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${MASTER}" "https://${DOMAIN_HOST}/indexes" || true)"
[ "$auth" = "200" ] || die "authenticated /indexes returned ${auth}, not 200"
curl -sS -H "Authorization: Bearer ${MASTER}" "https://${DOMAIN_HOST}/keys" | grep -q 'Default Search API Key' \
	|| die "/keys did not list the default search key"
unset MASTER

# --- 7. First backup ---------------------------------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/meilisearch-${STAMP}.tar.gz" \
	-C "$APP_DIR" data .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/meilisearch-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Meilisearch is answering at https://${DOMAIN_HOST}

	  1. There is no browser sign-in. The API expects Authorization: Bearer keys.
	  2. Master key: sudo grep MEILI_MASTER_KEY ${APP_DIR}/.env
	     Store it offline. Never put it in a frontend.
	  3. List keys (search-scoped for browsers):
	       MASTER=\$(grep MEILI_MASTER_KEY ${APP_DIR}/.env | cut -d= -f2-)
	       curl -sS -H "Authorization: Bearer \${MASTER}" https://${DOMAIN_HOST}/keys
	  4. Unauthenticated /indexes returns 401 (asserted above).
	  5. First backup at ${APP_DIR}/backups (includes .env). Copy it off this disk.
	  6. NOT YET VERIFIED on a clean harness machine.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/algolia/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
