Self-host Hister: your personal search engine
Run Hister on a VPS to search the full text of pages you visited and files you keep: binary and Docker installs, TLS, a login, and the MCP endpoint.
What Hister is, and what it is not
Hister is a personal search engine you self-host. It indexes the full text of the pages you visited and the files you keep, then lets you search that collection from a web interface, a terminal client, an HTTP API, or an AI (artificial intelligence) assistant. Hister answers one question: where did I read that.
Most readers meet this idea through SearXNG, and the two are not the same tool. SearXNG is a metasearch proxy. Your query goes to it, it asks other engines on your behalf, and it hands back their results with the tracking removed. The index belongs to those engines. Hister builds its own index out of content you gave it: pages captured by a browser extension, imported browser history, crawled URLs, and files in directories you point it at. A self-hosted SearXNG instance gives you private access to the public web. Hister gives you search over your own reading. The jobs are different, so running both on one box is normal.
Hister is free software under the AGPLv3 (GNU Affero General Public License, version 3) or later. It has no telemetry and needs no cloud service. This guide pins version v0.17.0, which was the current release on 2026-07-28. Check the releases page for the current tag before you copy anything, then pin the tag you find there.
Why self-host Hister on a VPS
An index is only useful when it is complete, and it is only complete if the server was running while you were reading. A laptop sleeps for half the day. Pages you open on your phone during that time never reach it, and an overnight import never starts. A VPS (virtual private server) stays up, so every device you own posts into the same index and the crawler keeps working while you sleep.
The second reason is separation. Setting user_handling: true in the app section gives each account its own credentials and its own document collection on a single instance. One server can then hold a household or a small team without anyone searching anyone else's reading.
The third reason is plumbing. The VPS already has a public hostname and a certificate, which is what the browser extension needs to reach the server from a network you do not control.
Install path one: the release binary
Hister ships one binary per platform. Download it together with the checksums file, and verify before installing.
cd /tmp
curl -LO https://github.com/asciimoo/hister/releases/download/v0.17.0/hister_0.17.0_linux_amd64
curl -LO https://github.com/asciimoo/hister/releases/download/v0.17.0/hister_0.17.0_checksums.txt
sha256sum --ignore-missing -c hister_0.17.0_checksums.txtA healthy result is the single line hister_0.17.0_linux_amd64: OK. A FAILED line means the download is damaged or altered, so fetch it again instead of installing it.
Install the binary, then create a system account and the directories it will use.
sudo install -m 755 /tmp/hister_0.17.0_linux_amd64 /usr/local/bin/hister
sudo useradd --system --home-dir /var/lib/hister --shell /usr/sbin/nologin hister
sudo install -d -o hister -g hister -m 750 /var/lib/hister
sudo install -d -m 755 /etc/hister
sudo hister create-config /etc/hister/config.ymlcreate-config writes a default configuration file, and it also proves the binary runs on this machine. A download for the wrong architecture fails right here, with cannot execute binary file: Exec format error.
Edit the few settings that matter. The rest of the generated file can stay as it is.
app:
directory: /var/lib/hister
access_token: 'paste-a-long-random-string-here'
server:
address: 127.0.0.1:4433
base_url: https://hister.example.comGenerate the token with openssl rand -hex 32. The file now holds a credential, so restrict it before the service ever starts.
sudo chown root:hister /etc/hister/config.yml
sudo chmod 640 /etc/hister/config.ymlRun it under systemd
Write /etc/systemd/system/hister.service:
[Unit]
Description=Hister personal search engine
After=network-online.target
Wants=network-online.target
[Service]
User=hister
Group=hister
Environment=HISTER_CONFIG=/etc/hister/config.yml
ExecStart=/usr/local/bin/hister listen
Restart=on-failure
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/hister
[Install]
WantedBy=multi-user.targetHISTER_CONFIG is the documented environment variable for the config path, so the unit does not depend on the home directory of the hister account. ProtectSystem=strict makes the whole filesystem read-only for this service, which is why ReadWritePaths has to name the data directory. ProtectHome=yes hides /home from the service, so a watched directory under /home would look empty to the indexer. Drop that line if you need to index files there.
sudo systemctl daemon-reload
sudo systemctl enable --now hister
systemctl status hister --no-pager
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4433/Any HTTP status code printed by that last command means the process is listening. curl: (7) Failed to connect means it is not, and journalctl -u hister -n 50 --no-pager will say why.
Install path two: Docker Compose
The image is published on the GitHub container registry, with one tag per release.
services:
hister:
image: ghcr.io/asciimoo/hister:v0.17.0
container_name: hister
user: '1000:1000'
restart: unless-stopped
environment:
- HISTER__SERVER__ADDRESS=0.0.0.0:4433
- HISTER__SERVER__BASE_URL=https://hister.example.com
- HISTER__APP__ACCESS_TOKEN=${HISTER_ACCESS_TOKEN}
volumes:
- ./data:/hister/data
ports:
- 127.0.0.1:4433:4433Every configuration key has an environment override shaped HISTER__<SECTION>__<KEY>, with two underscores as the separator, so a container deployment needs no mounted config file. Keep HISTER_ACCESS_TOKEN in a .env file next to the compose file. If you would rather edit a file, docker run --rm ghcr.io/asciimoo/hister:v0.17.0 create-config > config.yml prints the defaults.
Two lines above are easy to get wrong, and both are worth understanding.
The address inside the container must be 0.0.0.0:4433. A container has its own network namespace, so a process bound to 127.0.0.1 there is reachable only from inside that container, and the published port has nothing to forward to.
The published port is written 127.0.0.1:4433:4433, not 4433:4433. Docker publishes ports by inserting its own netfilter rules, and those rules are evaluated before the ufw rules, so a plain 4433:4433 stays reachable from the internet even on a box where ufw status shows the port closed. Binding the host side to 127.0.0.1 leaves the reverse proxy as the only way in. The same trap applies to every container on the server, and Docker Compose on a VPS covers the rest of that ground.
The default image runs as UID 1000 and GID 1000, so ./data has to be writable by that account or the container stops at startup with a permission error. sudo chown -R 1000:1000 ./data fixes it. If those numbers are unfamiliar, read which UID and GID a container writes files as first.
Why a personal search index is the worst thing to expose
Hister listens on 127.0.0.1:4433 by default, and that default is deliberate. Think about what the index holds after a month of use: internal wiki pages, invoices, support tickets you opened while logged in, password reset pages, and the full text of everything else you read. The project documentation states it directly: "Hister transmits your entire browsing history, with page contents, to and from the server."
A leaked password database still has to be cracked. A leaked personal index is plain text and already searchable, so it deserves more care than the small self-hosted app it resembles.
Two facts follow from that. Hister requires no authentication out of the box, so a reverse proxy on its own publishes a searchable copy of your reading to anyone who learns the hostname. The MCP endpoint is also served by default at /mcp, and without a token any client that reaches it can run a search against the index.
Configure authentication before the service leaves localhost for the first time. A single user needs only app.access_token, one shared secret sent by the browser extension, the terminal client and any MCP client. For several people, set user_handling: true and create accounts:
sudo -u hister hister create-user alice --admin --config /etc/hister/config.ymlThe command prompts for a password of at least 8 characters. Each account gets its own documents and a personal API token, which the owner can regenerate from the profile page or with the --regen-token flag on hister update-user. Generating a new token invalidates the previous one immediately, so every device that account uses has to be updated afterwards.
Leave app.public alone unless you mean it. Public mode allows unauthenticated search, previews, file serving and MCP search, while still blocking writes, history access and admin operations.
Reverse proxy, TLS and the firewall
Hister does not serve HTTPS itself, so terminate TLS (transport layer security) in front of it. Caddy is the shortest path, because it requests and renews certificates on its own over ACME (automatic certificate management environment).
hister.example.com {
reverse_proxy 127.0.0.1:4433
}Reload it with sudo systemctl reload caddy. Two conditions must hold before a certificate can be issued: the A record for hister.example.com has to point at this server, and port 80 has to be open, because the HTTP-01 challenge is answered there. When either is missing, the browser gets a TLS error instead of the page and the Caddy log repeats the failed challenge.
Then close everything else.
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw statusPort 4433 is missing from that list on purpose.
server.base_url must match the address you type in the browser, scheme included. When it does not match, the interface loads with unstyled text and missing images, because the server builds its asset links from base_url and the browser then requests them from an origin that does not answer. That same URL goes into the browser extension.
Filling the index
The browser extension is the main collector. Install it from Mozilla Add-ons or the Chrome Web Store, open its options page, set the server URL to https://hister.example.com, and paste the access token. It then captures the title, full text, HTML and favicon of each page you visit and posts them to your server. Extraction happens client side, inside the browser. The extension contacts no third party, and the only outside request it makes is for the page favicon.
Client-side extraction is what makes a private index possible. The extension sees a page exactly as you see it, after login and after rendering, so an internal wiki page or a paid article is indexed correctly and your server never needs the credentials. It also means everything you look at is a candidate for the index, which is why skip rules come before more content.
Skip rules live in rules.json on a single-user install, or per user in the database, and the Rules tab in the web interface is the easiest way to edit them. They are Go regular expressions matched against the full URL:
^https://mail\.example\.com
^https://bank\.example\.com
.*?utm_source=A pattern like ^mail.example.com never matches, because the string being tested starts with https://. A trailing $ also fails on any URL carrying a query string, since query parameters are kept during matching.
Existing history is imported by reading the browser's own database, so that command runs on the machine holding the browser profile, which is your laptop and not the VPS. Install the same binary there and point it at the server:
export HISTER_TOKEN='your-access-token'
hister import browser firefox -u https://hister.example.com -t "$HISTER_TOKEN"An import runs as a resumable job named browser-import-YYYY-MM-DD, so you can interrupt it and start it again later. Bookmark services import the same way, including Linkwarden, Karakeep, Wallabag, Linkding, Readeck and Shaarli, and a repeat import fetches only what is newer than the last one.
Files on the server are indexed by naming directories in the config:
indexer:
directories:
- path: '/var/lib/hister/documents'
label: 'documents'
filetypes: ['pdf', 'docx', 'md', 'txt']PDF, DOCX, Markdown, Org mode and valid UTF-8 text files are read as full text. Photos and video are not on that list, so an image library needs a server that indexes faces, places and dates rather than text, and PhotoPrism and Immich are the two that usually get compared for that job. A single page is added with hister index https://example.com. Turning whole sites into clean text for other tools is a separate job, handled by self-hosted crawlers that convert pages into clean text.
Search is field-based, so the query language repays ten minutes of reading:
"connection reset" domain:github.com added:<30d
title:(wireguard|nftables) -tutorial sort:-visitsPoint a coding agent at your own index over MCP
MCP (model context protocol) is the interface an assistant uses to call tools on a server. Hister serves it at POST /mcp under the same base URL, over the streamable HTTP transport, and exposes search, get_preview and get_history. Authentication is the same bearer token as the rest of the API.
{
"mcpServers": {
"hister": {
"url": "https://hister.example.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_ACCESS_TOKEN"
}
}
}
}An X-Access-Token header works as an alternative to Authorization.
The value here is in what the agent searches. Open web search returns whatever ranks today, which for fast-moving software is often documentation for a version you are not running. Your own index returns the page you already read and chose to keep, and get_preview serves the stored copy, so the answer survives the original page going offline. Give the agent both sources if you want public results too: a browser search skill backed by SearXNG adds the open web as a separate tool. Once you run more than one of these endpoints, hosting MCP servers on a VPS is worth reading, because every one of them shares this exposure problem.
Disk, backups and upkeep
The documentation puts one indexed page at around 100 KB, counting the compressed preview, so a hundred thousand pages is roughly 10 GB. There is no quota system. Two settings get mistaken for one: indexer.max_file_size_mb (1 MiB by default) limits a single watched file, and server.max_batch_body_size (40 MiB by default) limits one API request.
The directory named by app.directory holds index.db with the per-language index files, db.sqlite3 for accounts and jobs, data/html/ for previews, and rules.json. A backup is a stopped service plus a copy of that whole directory and the config file. hister export backup.json writes documents as JSON for migration, and it is not a server backup.
Two maintenance commands are worth knowing. hister reindex rebuilds the search indexes, which is required after changing indexer settings. If memory use climbs during a large import, set detect_languages: false in the indexer section and reindex. hister cleanup removes orphaned preview and favicon files left behind by deletions.
Deleting is a query, so run it in dry mode first:
hister delete 'domain:example.com' --dry --verboseA deleted page comes back if a collector still submits it, so add the skip rule before you delete.
The AGPLv3 only starts to matter if you change the code. Running an unmodified copy for yourself carries no obligation. If you modify Hister and let other people use your version over a network, the licence requires you to offer them your modified source.
Failure modes and the strings you will see
The server does not start. Either port 4433 is already taken, or the config file has a YAML syntax error. sudo ss -lntp | grep 4433 shows what holds the port, and journalctl -u hister -n 50 --no-pager prints the parse error.
The interface loads but looks broken. Jumbled text and missing images mean server.base_url does not match the URL in the address bar. A trailing slash counts as a mismatch.
The extension does not connect. The server URL in the extension has to equal base_url, the server has to be running and current, and a firewall in between blocks it with no message in the page. Firefox keeps extension logs out of the normal console: open about:debugging#/runtime/this-firefox and inspect the Hister extension.
The container exits at startup. A permission error on ./data means the directory is owned by a UID other than 1000, which is the account inside the default image.
403 Forbidden from an admin route. POST /api/reindex and POST /api/cleanup are admin-only when user handling is on, so an ordinary account is refused there.
Memory climbs during an import. Language detection over a large history is the usual cause. Set detect_languages: false and run hister reindex afterwards.
FAQ
How is Hister different from SearXNG?
SearXNG is a metasearch proxy: it forwards your query to public engines and returns their results with the tracking stripped out, so the index belongs to those engines. Hister keeps its own full-text index of the pages you visited and the files you keep, so it answers "where did I read that" while SearXNG answers "what does the web say". They solve different problems, and many people run both on one server.
Is it safe to put my whole browsing history on a VPS?
Only with the exposure work done first. Hister binds to 127.0.0.1:4433 and requires no authentication by default. Set app.access_token or user_handling: true, put a reverse proxy with TLS in front of it, and keep port 4433 closed at the firewall. A full-text index of your reading is plain text, so anyone who reaches the port can read everything without cracking anything.
Do I need the browser extension, or can I just import my history?
The import is a one-time backfill. It reads the browser's own history database, so it runs on the computer that holds the browser profile rather than on the server. The extension keeps the index current from then on, and it captures pages behind a login because it extracts the content in the browser after the page renders. A common setup is one import, then the extension.
Can a coding agent search my Hister index?
Yes. Hister is an MCP (model context protocol) server at POST /mcp on your base URL, exposing search, get_preview and get_history. Point the client at https://your-host/mcp with an Authorization: Bearer header holding your access token. The agent then searches the documentation you actually read, at the version you read it, instead of whatever ranks in a public search engine today.