Can I self-host Readwise?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Karakeep. It takes one prompt, a 4096 MB VPS, and about 100 minutes. That is $12.99 a month you stop paying Readwise — $155.88 a year on the Full plan.

Why people pay for Readwise

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Readwise sells the loop between reading and remembering. It pulls your highlights out of Kindle, Apple Books, Instapaper, Twitter and a dozen other places, mails you a handful of them every morning, and pushes the whole library into Notion or Obsidian so the notes end up where you actually write. The full plan then adds Reader, its own read-later app, so capture and review live in one subscription. What you are paying for is less the software than the integrations: the sync connectors are the part nobody wants to rebuild, and they are the part you cannot take with you.

Readwise plans and list prices
PlanList priceWhat it buys
Lite$6.99/moMonth-to-month figure from the payment panel on the pricing page; billed annually it is $5.59 a month, charged as $67.08 for 12 months. Daily Review by email and in the app, highlight browsing and search, sync from all sources. No Reader.
Fullthe plan this page prices against$12.99/moMonth-to-month figure from the payment panel on the pricing page; billed annually it is $9.99 a month, charged as $119.88 for 12 months. Adds tags and notes on highlights, export to Notion, Obsidian and others, and the Reader read-later app. This is the plan a self-hosted replacement is measured against. A 30-day free trial and a 50% academic discount are advertised on the same page.

Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: high

Replaced by Karakeep

One project, named before the prompt, so you know what you are about to install.

One inbox for every link, note, image and PDF you meant to come back to, archived against link rot and searchable years later.

The closest thing to the shape people actually use Readwise for: one inbox you throw everything into, from a phone share sheet, a browser extension or an RSS feed, and an archive underneath that keeps a readable copy when the page dies. It tags what you save with an AI model, so the pile stays findable without you filing anything, and it full-text searches links, notes, images and PDFs together. Two honest gaps. It does not sync Kindle or Apple Books highlights and it sends no daily review email, so the spaced-repetition half of Readwise is absent. And the AI tagging is your own OpenAI key or your own Ollama box, so a bill Readwise folded into the subscription becomes a separate small one you watch. It costs three containers, including a headless browser that fetches whatever anyone with an account asks it to, which is why the install closes signups and proves they are closed.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Karakeepour pickONE EVENING— setup effort 2 of 4

    One inbox for every link, note, image and PDF you meant to come back to, archived against link rot and searchable years later.

    The closest thing to the shape people actually use Readwise for: one inbox you throw everything into, from a phone share sheet, a browser extension or an RSS feed, and an archive underneath that keeps a readable copy when the page dies. It tags what you save with an AI model, so the pile stays findable without you filing anything, and it full-text searches links, notes, images and PDFs together. Two honest gaps. It does not sync Kindle or Apple Books highlights and it sends no daily review email, so the spaced-repetition half of Readwise is absent. And the AI tagging is your own OpenAI key or your own Ollama box, so a bill Readwise folded into the subscription becomes a separate small one you watch. It costs three containers, including a headless browser that fetches whatever anyone with an account asks it to, which is why the install closes signups and proves they are closed.

  2. LinkwardenONE EVENING— setup effort 2 of 4

    Bookmarks that keep their own copy of the page, so a dead link is still readable years later.

    Linkwarden is the pure link-archive answer and holds its own page in this catalog as the Raindrop replacement, where it ranks first. It keeps a screenshot, a PDF and a single-file HTML copy of every page it saves, and its collections and collaboration are better thought through than Karakeep's lists. Karakeep ranks first here rather than Linkwarden for one reason: the Readwise shape is a capture-everything inbox with automatic organisation and something to read in, and Karakeep takes notes, images and PDFs as first-class items, tags them without being asked, and ships a reader view and mobile apps. If all you save is links and you would rather not run a headless browser and a search engine, Linkwarden is the smaller install and the better fit.

The swap

You're paying

Readwise

$12.99/mo · $155.88/yr

is replaced by

You'd run

Karakeep

ONE EVENING · ~100 min to running · 4096 MB RAM

Readwise Full · vendor list price · checked 2026-08-14 · source

Before you start

RAM floor
4096 MBfloor from upstream docs — not measured by us yet
Disk
20 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~100 min1–3 hours, through the first backup

The prompt

Two paths to the same Karakeep: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

335 lines · 14,999 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 Karakeep 0.33.2 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, and it becomes `NEXTAUTH_URL` in step 3.

Say this to the user first. Karakeep saves a page by driving a real headless browser inside this
server's network, so anyone with an account can make this box fetch a URL of their choosing and
read the result. Upstream's first mitigation is limiting access to trusted users, and the first
person to register is the administrator. Step 7 closes signups and proves it.

Karakeep needs 4096 MB of RAM available and 20 GB free on /srv. All three images publish amd64
and arm64.

```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 4096 MB or free disk is under 20 GB, print both and stop. The memory
floor is a headless Chrome while Meilisearch holds its index; the disk floor is the third month
of screenshots. If `dig +short` prints nothing, print that and stop too.

## 2. Layout

Four directories, two owners: the Karakeep and Meilisearch images run as root and write to
their mounts, so those two stay with root.

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

Assert: `backups` owned by the login user, `data` and `meili` owned by `root`, all mode `750`.
Keep `data` on local disk: SQLite on a network mount corrupts.

## 3. Secrets

Two secrets. `NEXTAUTH_SECRET` signs the session tokens; `MEILI_MASTER_KEY` is the only
credential the search engine accepts. Upstream documents the generator below for both, base64
for the first and alphanumerics only for the second. Generate both on the server, do not print
either, and keep them out of your summary and every log line. Two values here are not secrets:
`NEXTAUTH_URL` is the address Karakeep hands out, and `DISABLE_SIGNUPS` is open for one step,
until step 7 closes it.

```bash
umask 077
cat > /srv/karakeep/.env <<EOF
NEXTAUTH_URL=https://<DOMAIN>
DISABLE_SIGNUPS=false
NEXTAUTH_SECRET=$(openssl rand -base64 36)
MEILI_MASTER_KEY=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9')
EOF
chmod 600 /srv/karakeep/.env
umask 022
ls -l /srv/karakeep/.env
```

Assert: mode `-rw-------`, and `NEXTAUTH_URL` reads `https://` and the real hostname. The user
reads both with `sudo grep -E 'NEXTAUTH_SECRET|MEILI' /srv/karakeep/.env`.

## 4. compose.yml

```bash
cat > /srv/karakeep/compose.yml <<'EOF'
# Karakeep · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.karakeep.app/installation/docker
#   configuration ...... https://docs.karakeep.app/configuration/environment-variables
#   minimal install .... https://docs.karakeep.app/installation/minimal-install
#   image build ........ https://github.com/karakeep-app/karakeep/blob/v0.33.2/docker/Dockerfile
#
# Three services. `web` is upstream's all-in-one image: app, workers and the
# migration under s6, with SQLite in /data, so no Postgres appears here.
# `chrome` renders and screenshots pages; upstream says that without it
# javascript pages crawl badly. `meilisearch` is the search engine, without
# which upstream says search is disabled completely; it is pinned to the
# 1.41.0 Karakeep is built against, not the newer line on Meilisearch's own
# page here. MEILI_MASTER_KEY comes from the .env beside this file, so run
# compose from /srv/karakeep. Digests read 2026-08-14; all three ship arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.41.0@sha256:860fa4baed04ae1c235de870edab0c8006227546dea1bbb6411fbfc5e27cf1db
    container_name: karakeep-meilisearch
    restart: unless-stopped
    environment:
      # Production mode refuses to start without a master key.
      MEILI_ENV: production
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      # The index. Rebuilt from the database, so step 8 leaves it out.
      - /srv/karakeep/meili:/meili_data
    # No `ports:`: 7700 is reachable only from the other containers.

  chrome:
    image: ghcr.io/karakeep-app/karakeep-chrome:151.0.7922.47-r1@sha256:5b19bbb160e9ff60681a3abd97e1c4ec9f64212301410de658c3900ab7ef31e7
    container_name: karakeep-chrome
    restart: unless-stopped
    init: true
    command:
      - --disable-gpu
      - --disable-dev-shm-usage
      - --hide-scrollbars
      - --disable-blink-features=AutomationControlled
      - --window-size=1440,900
    # No `ports:` and no volume: 9222 remote-controls a real browser with no
    # credential, and only the web container shares this network.

  web:
    image: ghcr.io/karakeep-app/karakeep:0.33.2@sha256:b069e4307dec06ea06d16989c6861c30a1ff208568be44ed5fb5d422cd3e950c
    container_name: karakeep
    restart: unless-stopped
    env_file: /srv/karakeep/.env
    environment:
      MEILI_ADDR: http://meilisearch:7700
      BROWSER_WEB_URL: http://chrome:9222
      # Upstream's compose says DON'T CHANGE THIS. The mount moves.
      DATA_DIR: /data
      # The image ships debug. `info` is the quietest level it defines.
      LOG_LEVEL: info
    volumes:
      # The SQLite database and every archived asset: the product.
      - /srv/karakeep/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8182.
      - "127.0.0.1:8182:3000"
    # Start ordering only: web connects to both lazily and retries.
    depends_on:
      - meilisearch
      - chrome
EOF
cd /srv/karakeep && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`, with no warning that `MEILI_MASTER_KEY` is unset. Compose
reads it from the .env beside this file, so such a warning means the wrong directory: run every
compose command from /srv/karakeep.

## 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
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-karakeep
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Karakeep · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.karakeep.app/installation/docker 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. That hostname is
# also NEXTAUTH_URL in .env, https:// and no trailing slash: Caddy speaks plain
# http to the container, so that variable is what tells Karakeep it is https.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# One list can be public while the rest of the app sits behind a
		# login on the same hostname. SAMEORIGIN protects the second half.
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8182 is the loopback port compose publishes on this host: not a container
	# port, and not open in the firewall. Caddy sets no body limit, so an
	# upload's ceiling is MAX_ASSET_SIZE_MB, default 50.
	reverse_proxy 127.0.0.1:8182
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-karakeep, reload, and report what it objected to. This hostname and
`NEXTAUTH_URL` must match. Caddy gets the certificate on first request and renews it.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box 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, 443/udp is
HTTP/3. 8182 is bound to 127.0.0.1, and 7700 and 9222 are never published; 9222 is the one that
matters, because it remote-controls a browser with no credential. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8182, 7700 or 9222.

## 7. Start and verify

The web image runs the migration, the app and the workers together under s6, so the first boot
writes the schema before it answers. The pull is about a gigabyte, so use the loop.

```bash
cd /srv/karakeep
docker compose pull
docker compose up -d
for i in $(seq 1 36); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health; echo
curl -sSL https://<DOMAIN>/signin | grep -c 'Welcome Back'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/bookmarks
docker compose exec -T web curl -sS -o /dev/null -w '%{http_code}\n' http://meilisearch:7700/indexes
curl -sSL https://<DOMAIN>/signup | grep -c 'Create Your Account'
```

Assert all six, printing what you received. The loop ends at `200`. The health call prints
`{"status":"ok","message":"Web app is working"}`. The third is above `0`: `Welcome Back` is the
sign-in heading, the first screen here. The REST call prints `401`, and Meilisearch prints `401`
from inside the compose network, which is what `MEILI_MASTER_KEY` bought. The last is above `0`:
the open door.

On any miss, stop, run `docker compose logs --tail 40 web`, then
`docker compose logs --tail 20 meilisearch`, and name the earlier step. A 502 with all three up
points at step 5; a Meilisearch answering `200` means the master key never reached it, which is
step 4 and the working directory. A running container is not success.

STOP: tell the user to open https://<DOMAIN>/signup now, create their account with a password
their password manager generates, and wait. Do not continue until they confirm. Say why the
hurry: the first account created here is the administrator, and until they make it that offer
stands for anyone who knows the hostname.

Once they confirm, shut the door and prove it is shut:

```bash
sed -i 's/^DISABLE_SIGNUPS=false$/DISABLE_SIGNUPS=true/' /srv/karakeep/.env
cd /srv/karakeep && docker compose up -d --force-recreate --no-deps web
sleep 20
docker compose exec -T web printenv DISABLE_SIGNUPS
curl -sSL https://<DOMAIN>/signup | grep -c 'Create Your Account'
```

Assert both: `printenv` prints `true` from inside the running container, and the grep prints
`0`. Both must pass before you report success. The recreate matters and a restart will not do:
compose reads `.env` only when it creates a container, so `docker compose restart` would leave
registration open. If `printenv` prints `false`, recreate again and do not go on.

STOP: tell the user to sign in, paste any article URL into the bookmark box, and watch that card
for a minute. Do not continue until they confirm they see a real title rather than a bare URL.
That one card is the app, a worker, Chrome and the search engine all answering.

## 8. First backup and restore

One archive: the database and every archived asset, the environment file, the compose file and
the live Caddy site block. The index is left out, because Meilisearch rebuilds it from the
database with Reindex All Bookmarks.

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

Assert: the archive exists and is non-empty. Print its size. The containers are stopped because
a SQLite file copied mid-write is not a backup.

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

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

To restore: `docker compose down`, `sudo rm -rf /srv/karakeep/data /srv/karakeep/meili`,
recreate both as in step 2, untar the archive into /srv/karakeep, restore the Caddy block if
that is what was lost, then `docker compose up -d` and reindex. `.env` has to be in place before
that first start, or a container created without `MEILI_MASTER_KEY` writes an index the restored
key cannot open. `data` holds every bookmark, highlight and archived page.

## 9. Updating later

New versions are listed at https://github.com/karakeep-app/karakeep/releases. The release tag
carries a `v` and the image tag does not, so `v0.34.0` is image tag `0.34.0`. Back up
first, then edit the `web` image line in compose.yml to the new tag and digest:

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

Leave Meilisearch alone. It is pinned to 1.41.0 on purpose: upstream names that as the version
Karakeep is built against and advises against upgrading it alone, because a newer engine refuses
an older index, and the recovery is erasing `data.ms` and reindexing every bookmark. Karakeep
migrates its own schema on the way up, so watch that log settle, then re-run step 7's health
check.

## 10. What will probably go wrong

The first bookmark will look like a broken install. I pasted a URL, got a card with the raw
address on it and nothing else, refreshed twice and started reading logs. Nothing was wrong: the
crawl is a background job, the browser has to start, render and screenshot the page, and on a
small box the first one took most of a minute while the card sat there empty, and the page does
not refresh itself. Give it sixty seconds and reload before touching anything. If the title is
still missing, read `docker compose logs --tail 40 web` for the crawler line rather than
restarting: a failed crawl says so, and the usual cause is a Chrome container that never came up.

## 11. Out of scope

- Do not set `OPENAI_API_KEY` or `OLLAMA_BASE_URL`. AI tagging bills the user's own account.
- Do not turn on `CRAWLER_FULL_PAGE_ARCHIVE`, `CRAWLER_STORE_PDF` or `CRAWLER_VIDEO_DOWNLOAD`.
  Each multiplies the disk this install eats, and that trade wants a month of usage first.
- Do not configure SMTP or `EMAIL_VERIFICATION_REQUIRED`. There is nobody to mail on a
  one-account install, and verification on a closed instance locks the owner out.
- Do not publish 3000, 7700 or 9222 or open them in the firewall. 9222 drives a browser for
  whoever reaches it.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

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 Karakeep 0.33.2 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 this before step 1, because it decides whether you want this at all. Karakeep saves a page
by driving a real headless browser inside your server's network, so anyone who holds an account
here can make this box fetch a URL of their choosing and then read the result. Upstream's first
mitigation for that is limiting access to trusted users, and Karakeep gives the administrator
role to whoever registers while the users table is empty. Step 7 is where you claim that account
and shut the door behind you, and the minutes between starting the containers and finishing that
form are the only window this install has.

## 1. Preflight

```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>
```

You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that does
not resolve and failed attempts count against a rate limit you cannot see. On the memory line,
4096 MB is not padding: a headless Chrome renders a full page while Meilisearch holds its index
in memory, and a 2 GB box gets through the install and then starts losing crawls to the OOM
killer, which looks random and is not. The 20 GB is about the third month rather than the first
day, because every crawled link can leave a screenshot and a cached image behind.

## 2. Layout

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

You should see: `backups` owned by you, `data` and `meili` owned by `root`, all at mode `750`.

If you do not: those owners are deliberate. The Karakeep image and the Meilisearch image both
run as root inside their containers and write to their mounts, so a directory owned by your
login user is one they would have to be given permission for, and root-owned is the honest
answer rather than a chmod that hides the question. Keep `/srv/karakeep/data` on the server's
local disk: the database is a SQLite file, and a network mount corrupts one quietly, weeks
later, in a way no error message names.

## 3. Secrets

Two secrets, both generated here on the server, both landing in a file only you can read.
`NEXTAUTH_SECRET` signs your session tokens. `MEILI_MASTER_KEY` is the only credential the
search engine accepts, and upstream documents this exact pair of commands for them. Two more
values sit in the same file and are not secrets: `NEXTAUTH_URL` is the public address Karakeep
hands out, spelled `https://` with no trailing slash, and `DISABLE_SIGNUPS` is open for exactly
one step.

```bash
umask 077
cat > /srv/karakeep/.env <<EOF
NEXTAUTH_URL=https://<DOMAIN>
DISABLE_SIGNUPS=false
NEXTAUTH_SECRET=$(openssl rand -base64 36)
MEILI_MASTER_KEY=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9')
EOF
chmod 600 /srv/karakeep/.env
umask 022
ls -l /srv/karakeep/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens when
the lines are pasted separately into different shells. Run `chmod 600 /srv/karakeep/.env` and
carry on. If the file already existed from an earlier attempt this block has now replaced both
secrets, which is harmless before the first start and awkward after: a new `MEILI_MASTER_KEY`
cannot open an index written under the old one, and the fix is Reindex All Bookmarks from the
admin screens once you are signed in.

Do not paste that file, either secret, or any command output containing them into this chat
window. Read them yourself with
`sudo grep -E 'NEXTAUTH_SECRET|MEILI_MASTER_KEY' /srv/karakeep/.env` and put them in your
password manager. Changing `NEXTAUTH_SECRET` later signs everybody out.

## 4. compose.yml

```bash
cat > /srv/karakeep/compose.yml <<'EOF'
# Karakeep · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.karakeep.app/installation/docker
#   configuration ...... https://docs.karakeep.app/configuration/environment-variables
#   minimal install .... https://docs.karakeep.app/installation/minimal-install
#   image build ........ https://github.com/karakeep-app/karakeep/blob/v0.33.2/docker/Dockerfile
#
# Three services. `web` is upstream's all-in-one image: app, workers and the
# migration under s6, with SQLite in /data, so no Postgres appears here.
# `chrome` renders and screenshots pages; upstream says that without it
# javascript pages crawl badly. `meilisearch` is the search engine, without
# which upstream says search is disabled completely; it is pinned to the
# 1.41.0 Karakeep is built against, not the newer line on Meilisearch's own
# page here. MEILI_MASTER_KEY comes from the .env beside this file, so run
# compose from /srv/karakeep. Digests read 2026-08-14; all three ship arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.41.0@sha256:860fa4baed04ae1c235de870edab0c8006227546dea1bbb6411fbfc5e27cf1db
    container_name: karakeep-meilisearch
    restart: unless-stopped
    environment:
      # Production mode refuses to start without a master key.
      MEILI_ENV: production
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      # The index. Rebuilt from the database, so step 8 leaves it out.
      - /srv/karakeep/meili:/meili_data
    # No `ports:`: 7700 is reachable only from the other containers.

  chrome:
    image: ghcr.io/karakeep-app/karakeep-chrome:151.0.7922.47-r1@sha256:5b19bbb160e9ff60681a3abd97e1c4ec9f64212301410de658c3900ab7ef31e7
    container_name: karakeep-chrome
    restart: unless-stopped
    init: true
    command:
      - --disable-gpu
      - --disable-dev-shm-usage
      - --hide-scrollbars
      - --disable-blink-features=AutomationControlled
      - --window-size=1440,900
    # No `ports:` and no volume: 9222 remote-controls a real browser with no
    # credential, and only the web container shares this network.

  web:
    image: ghcr.io/karakeep-app/karakeep:0.33.2@sha256:b069e4307dec06ea06d16989c6861c30a1ff208568be44ed5fb5d422cd3e950c
    container_name: karakeep
    restart: unless-stopped
    env_file: /srv/karakeep/.env
    environment:
      MEILI_ADDR: http://meilisearch:7700
      BROWSER_WEB_URL: http://chrome:9222
      # Upstream's compose says DON'T CHANGE THIS. The mount moves.
      DATA_DIR: /data
      # The image ships debug. `info` is the quietest level it defines.
      LOG_LEVEL: info
    volumes:
      # The SQLite database and every archived asset: the product.
      - /srv/karakeep/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8182.
      - "127.0.0.1:8182:3000"
    # Start ordering only: web connects to both lazily and retries.
    depends_on:
      - meilisearch
      - chrome
EOF
cd /srv/karakeep && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: a complaint about `MEILI_MASTER_KEY` being unset means you ran this from another
directory. Docker compose reads `.env` from the folder the compose file lives in, so every
compose command in this install runs after `cd /srv/karakeep`. A YAML error usually means the
heredoc was pasted in two pieces; delete the file and paste the whole block at once. Three
services here, and each one buys something named: the web container is the app, the workers and
the database migration together, the chrome container renders and screenshots pages, and
Meilisearch is the search engine that upstream says is the difference between search working and
search being switched off entirely.

## 5. Caddy and TLS

Copy the Caddyfile first. A syntax error here takes down every other site on this box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-karakeep
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Karakeep · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.karakeep.app/installation/docker 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. That hostname is
# also NEXTAUTH_URL in .env, https:// and no trailing slash: Caddy speaks plain
# http to the container, so that variable is what tells Karakeep it is https.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# One list can be public while the rest of the app sits behind a
		# login on the same hostname. SAMEORIGIN protects the second half.
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8182 is the loopback port compose publishes on this host: not a container
	# port, and not open in the firewall. Caddy sets no body limit, so an
	# upload's ceiling is MAX_ASSET_SIZE_MB, default 50.
	reverse_proxy 127.0.0.1:8182
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from the reload.
Replace `<DOMAIN>` in the block with your real hostname before you paste.

If you do not: restore the copy with
`sudo cp /etc/caddy/Caddyfile.before-karakeep /etc/caddy/Caddyfile`, reload, and read what
validate objected to. The usual cause is a `<DOMAIN>` left literal, which Caddy reads as a
hostname it is being asked to certify. The hostname in this block and `NEXTAUTH_URL` in .env
have to be the same string: Caddy terminates TLS and speaks plain http to the container, so that
variable is the only thing telling Karakeep its outside address is https.

## 6. Firewall

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

You should see: `Status: active`, and rules for 80/tcp, 443/tcp and 443/udp. Nothing for 8182,
7700, 9222 or 3000.

If you do not: an inactive firewall means Prompt Zero did not finish, and you should go back
rather than carry on. If a rule for 8182 is listed from an earlier attempt, remove it with
`sudo ufw delete allow 8182`. 8182 is bound to 127.0.0.1 by the compose file, so Caddy reaches
it and nothing else can. 7700 and 9222 are never published at all, and 9222 is the one that
would matter: it remote-controls a real browser for anything that can open a socket to it.

## 7. Start and verify

The web image runs the database migration, the app and the background workers together, so the
first boot writes the schema before it answers anything. The first pull is roughly a gigabyte
across three images, so the loop below is doing real waiting rather than being polite.

```bash
cd /srv/karakeep
docker compose pull
docker compose up -d
for i in $(seq 1 36); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
```

You should see: a column of numbers ending in `200`. The first several are usually `502` or
`000` while the images pull and the schema is written.

If you do not: if it never leaves `000`, the certificate is the problem, and
`sudo journalctl -u caddy --since -10min | tail -30` says so. If it sits at `502` with all three
containers running, Caddy is reaching the wrong port, so re-read step 5. If a container is
missing from `docker compose ps`, read `docker compose logs --tail 40 web`.

```bash
curl -sS https://<DOMAIN>/api/health; echo
curl -sSL https://<DOMAIN>/signin | grep -c 'Welcome Back'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/bookmarks
docker compose exec -T web curl -sS -o /dev/null -w '%{http_code}\n' http://meilisearch:7700/indexes
curl -sSL https://<DOMAIN>/signup | grep -c 'Create Your Account'
```

You should see: `{"status":"ok","message":"Web app is working"}`, then a number above `0`, then
`401`, then `401`, then a number above `0`.

If you do not: a `200` from the Meilisearch line instead of `401` means the master key never
reached that container, which is step 4 and the directory you ran it from, and it matters
because an unauthenticated search engine on your compose network will hand its whole index to
anything that can reach it. A `0` from the `Welcome Back` count with a healthy `/api/health`
means Caddy is proxying to something other than Karakeep. Anything other than `401` from the
bookmarks call means the API is answering unauthenticated requests, and you should stop and
work out why before going further.

Now claim the instance. That last count above `0` is a registration form open to whoever loads
this hostname, and the first account created on it becomes the administrator.

Open https://<DOMAIN>/signup in a browser, create your account with a password your password
manager generates, and come straight back. Do not wander off in the middle of this.

```bash
sed -i 's/^DISABLE_SIGNUPS=false$/DISABLE_SIGNUPS=true/' /srv/karakeep/.env
cd /srv/karakeep && docker compose up -d --force-recreate --no-deps web
sleep 20
docker compose exec -T web printenv DISABLE_SIGNUPS
curl -sSL https://<DOMAIN>/signup | grep -c 'Create Your Account'
```

You should see: `true`, then `0`.

If you do not: `false` from `printenv` means the container was not recreated. A plain
`docker compose restart` will not do it, because compose reads `.env` when it creates a
container and not when it restarts one, so the page keeps offering registrations while the file
on disk says otherwise. Run the `up -d --force-recreate --no-deps web` line again and check
again. Do not treat this install as finished until both lines are right.

Now sign in at https://<DOMAIN>, paste any article URL into the bookmark box, and watch that
card for a minute. When a real title and a preview image replace the bare URL, the whole stack
has answered at once: the app took it, a worker queued it, the chrome container rendered it, and
Meilisearch indexed it.

## 8. First backup and restore

One archive: the SQLite database and every archived asset, the environment file, the compose
file, and the live Caddy site block. The search index is deliberately left out, because
Meilisearch rebuilds from the database with Reindex All Bookmarks in the admin screens and an
index is not worth carrying twice.

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

You should see: one `.tar.gz` with a real size next to it, and the site answering again within a
few seconds.

If you do not: a `tar: Removing leading /` warning is normal and not an error. A zero-byte
archive means the `-C /srv/karakeep` path is wrong for your install. The containers are stopped
on purpose: a SQLite file copied while it is being written is not a backup, it is a file that
restores into a database with a hole in it.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not on
the server:

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

To restore, cold, at 2am: `cd /srv/karakeep`, `docker compose down`, then
`sudo rm -rf /srv/karakeep/data /srv/karakeep/meili`, recreate both directories exactly as in
step 2, untar the archive back into /srv/karakeep, put the Caddy block back into
/etc/caddy/Caddyfile if that is what was lost, then `docker compose up -d` and run Reindex All
Bookmarks once you are signed in. The order matters: `.env` has to be in place before the first
start, because a container created without `MEILI_MASTER_KEY` writes an index that the restored
key cannot open. What is in that archive, in plain terms: `data` is every bookmark, every
highlight, every archived page and your own account, and `.env` is the key that opens your
sessions and the search index.

## 9. Updating later

New versions are listed at https://github.com/karakeep-app/karakeep/releases. The release tag
carries a `v` and the image tag does not, so release `v0.34.0` is image tag `0.34.0`. Take a
backup first, then edit the `web` image line in /srv/karakeep/compose.yml to the new tag and its
digest, and run:

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

You should see: the migration lines scroll past, then the app reporting it is listening.

If you do not: leave Meilisearch alone while you debug. It is pinned to 1.41.0 on purpose,
because upstream names that as the version Karakeep is built against and advises against
upgrading it by itself: a newer engine refuses to open an older index, and the recovery upstream
documents is stopping that container, erasing the `data.ms` folder inside /srv/karakeep/meili,
starting it again, and reindexing every bookmark. Karakeep migrates its own SQLite schema on the
way up, so let that log settle before you decide anything is wrong, then re-run the health check
and the `Welcome Back` count from step 7.

## 10. What will probably go wrong

Your first bookmark will look like a broken install. I pasted a URL, got a card with the raw
address on it and nothing else, refreshed twice and started reading logs. Nothing was wrong: the
crawl is a background job, the browser has to start, render and screenshot the page, and on a
small box the first one took most of a minute while the card sat there empty. The page does not
refresh itself while you watch it either. Give it sixty seconds and reload before touching
anything. If the title is still missing, read `docker compose logs --tail 40 web` and look for
the crawler line rather than restarting: a crawl that failed says so, and the usual cause is a
chrome container that never came up, which `docker compose ps` shows in one line.

## 11. Out of scope

- Do not set `OPENAI_API_KEY` or `OLLAMA_BASE_URL` today. AI tagging is the piece of this that
  bills your own account, and it is worth adding deliberately once the rest is boring.
- Do not turn on `CRAWLER_FULL_PAGE_ARCHIVE`, `CRAWLER_STORE_PDF` or `CRAWLER_VIDEO_DOWNLOAD`.
  Each multiplies the disk this install eats, and that trade wants a month of real usage first.
- Do not configure SMTP or `EMAIL_VERIFICATION_REQUIRED`. There is nobody to mail on a
  single-account install, and verification on a closed instance locks you out of your own box.
- Do not publish 3000, 7700 or 9222 on the host or open them in the firewall. Caddy is the only
  way in, and 9222 drives a browser for whoever reaches it.

327 lines · 14,985 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 Karakeep 0.33.2 under ~/selfhost/karakeep, answering at http://localhost:8182.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Karakeep is a capture-everything inbox and most people capture from a phone. This one
answers at http://localhost:8182, this computer and nothing else, so the iOS and Android apps
cannot reach it. What is left is the browser extension and archives on their own disk.

Detect the OS and measure:

```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. Karakeep needs 4096 MB of RAM available and
20 GB free on the home disk, and all three images publish amd64 and arm64. On macOS and Windows
that memory figure is the host's, and Docker Desktop takes its slice out of it. Under either
floor, 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/karakeep/data ~/selfhost/karakeep/meili ~/selfhost/karakeep/backups
ls -la ~/selfhost/karakeep
```

Assert: `ls -la` shows `data`, `meili` and `backups`. No ownership fix is needed: both images
that write to disk run as root.

## 4. Secrets

Two secrets. `NEXTAUTH_SECRET` signs the session tokens; `MEILI_MASTER_KEY` is the only
credential the search engine accepts. Upstream documents the generator below for both, base64
for the first and alphanumerics only for the second, and Git Bash ships openssl. Generate both
here and print neither.

```bash
umask 077
cat > ~/selfhost/karakeep/.env <<EOF
NEXTAUTH_URL=http://localhost:8182
DISABLE_SIGNUPS=false
NEXTAUTH_SECRET=$(openssl rand -base64 36)
MEILI_MASTER_KEY=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9')
EOF
chmod 600 ~/selfhost/karakeep/.env
umask 022
ls -l ~/selfhost/karakeep/.env
```

Assert: mode `-rw-------`. On Windows those bits are advisory and the real boundary is the
user's account. Read both with `grep -E 'NEXTAUTH_SECRET|MEILI' ~/selfhost/karakeep/.env`.

## 5. compose.yml

```bash
cat > ~/selfhost/karakeep/compose.yml <<'EOF'
# Karakeep · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.karakeep.app/installation/docker
#   configuration ...... https://docs.karakeep.app/configuration/environment-variables
#   minimal install .... https://docs.karakeep.app/installation/minimal-install
#   image build ........ https://github.com/karakeep-app/karakeep/blob/v0.33.2/docker/Dockerfile
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/karakeep/, so one file works on macOS, Linux and Windows, and both
# stay bind mounts so you can open your archives in Finder or Explorer. Both
# images that write to disk run as root in their containers, so no chown is
# needed. `web` is the all-in-one image: app, workers and migration under s6,
# SQLite in /data. `chrome` renders and screenshots pages. `meilisearch` is the
# search engine, without which upstream says search is disabled completely,
# pinned to the 1.41.0 Karakeep is built against rather than the newer line on
# Meilisearch's own page here. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.41.0@sha256:860fa4baed04ae1c235de870edab0c8006227546dea1bbb6411fbfc5e27cf1db
    container_name: karakeep-meilisearch
    restart: unless-stopped
    environment:
      # Production mode refuses to start without a master key.
      MEILI_ENV: production
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      # The index. Rebuilt from the database, so step 8 leaves it out.
      - ./meili:/meili_data
    # No `ports:`: 7700 is reachable only from the web container.

  chrome:
    image: ghcr.io/karakeep-app/karakeep-chrome:151.0.7922.47-r1@sha256:5b19bbb160e9ff60681a3abd97e1c4ec9f64212301410de658c3900ab7ef31e7
    container_name: karakeep-chrome
    restart: unless-stopped
    init: true
    command:
      - --disable-gpu
      - --disable-dev-shm-usage
      - --hide-scrollbars
      - --disable-blink-features=AutomationControlled
      - --window-size=1440,900
    # No `ports:` and no volume: 9222 remote-controls a browser with no
    # credential, and only the web container is on this network.

  web:
    image: ghcr.io/karakeep-app/karakeep:0.33.2@sha256:b069e4307dec06ea06d16989c6861c30a1ff208568be44ed5fb5d422cd3e950c
    container_name: karakeep
    restart: unless-stopped
    env_file: ./.env
    environment:
      MEILI_ADDR: http://meilisearch:7700
      BROWSER_WEB_URL: http://chrome:9222
      # Upstream's compose says DON'T CHANGE THIS. The mount moves.
      DATA_DIR: /data
      # The image ships debug. `info` is the quietest level it defines.
      LOG_LEVEL: info
    volumes:
      # The SQLite database and every archived asset.
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8182.
      - "127.0.0.1:8182:3000"
    # Start ordering only: web connects to both lazily and retries.
    depends_on:
      - meilisearch
      - chrome
EOF
cd ~/selfhost/karakeep && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`, with no warning that `MEILI_MASTER_KEY` is unset. Compose
reads it from the .env beside this file, so run compose from ~/selfhost/karakeep.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. A certificate attests a public name that
nothing here has, and browsers treat http://localhost as secure anyway.

8182 is bound to 127.0.0.1, this computer only: no phone, no laptop on the wifi, nobody on the
internet. Confirm it:

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

Assert: that prints `1`, the single published port. The other two have no `ports:` line, and
9222 is the one that matters, because it remote-controls a browser with no credential.

## 7. Start and verify

The web image runs the migration, the app and the workers together under s6, so the first boot
writes the schema before it answers. The pull is about a gigabyte, hence the loop.

```bash
cd ~/selfhost/karakeep
docker compose pull
docker compose up -d
for i in $(seq 1 36); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8182/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8182/api/health; echo
curl -sSL http://localhost:8182/signin | grep -c 'Welcome Back'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8182/api/v1/bookmarks
docker compose exec -T web curl -sS -o /dev/null -w '%{http_code}\n' http://meilisearch:7700/indexes
curl -sSL http://localhost:8182/signup | grep -c 'Create Your Account'
```

Assert all six, printing what you received. The loop ends at `200`. The health call prints
`{"status":"ok","message":"Web app is working"}`. The third is above `0`: `Welcome Back` is the
sign-in heading. The REST call prints `401`, and Meilisearch prints `401` from inside the compose
network. The last is above `0`: that is the open door.

On any miss, stop, run `docker compose logs --tail 40 web`, then
`docker compose logs --tail 20 meilisearch`, and name the earlier step. If
`port is already allocated` came back, find what holds 8182 with
`lsof -nP -iTCP:8182 -sTCP:LISTEN` and stop until the user frees it. A running container is not
success.

STOP: tell the user to open http://localhost:8182/signup, create their account with a password
their password manager generates, and wait. Do not continue until they confirm.

Once they confirm, shut the door and prove it is shut:

```bash
cd ~/selfhost/karakeep
sed -i.bak 's/^DISABLE_SIGNUPS=false$/DISABLE_SIGNUPS=true/' .env && rm -f .env.bak
docker compose up -d --force-recreate --no-deps web
sleep 20
docker compose exec -T web printenv DISABLE_SIGNUPS
curl -sSL http://localhost:8182/signup | grep -c 'Create Your Account'
```

Assert both: `printenv` prints `true` from inside the running container, and the grep prints
`0`. `sed -i.bak` works on BSD sed as well as GNU, and the recreate matters because compose reads
`.env` only when it creates a container.

STOP: tell the user to sign in, paste any article URL into the bookmark box, and watch that card
for a minute. Do not continue until they confirm they see a real title rather than a bare URL.

## 8. First backup and restore

One archive: the database and every archived asset, the environment file and the compose file.
The index is left out; Meilisearch rebuilds it with Reindex All Bookmarks.

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

Assert: the archive exists and is non-empty. Print its size. The containers stop because a
SQLite file copied mid-write is not a backup.

That archive is on the same disk as the data, and on a laptop both fail together. Ask the user
for a destination that leaves this computer, a sync folder or a USB stick, and copy it with
`cp`. Assert: the user confirms it is there.

To restore: `cd ~/selfhost/karakeep`, `docker compose down`, `rm -rf data meili`, untar the
archive there, `mkdir -p meili`, then `docker compose up -d` and reindex. `.env` has to be back
before that first start, or a container created without `MEILI_MASTER_KEY` writes an index the
restored key cannot open. `data` holds every bookmark, highlight and archive.

## 9. Updating later

New versions are listed at https://github.com/karakeep-app/karakeep/releases. The release tag
carries a `v` and the image tag does not, so `v0.34.0` is image tag `0.34.0`. Back up, then edit
the `web` image line in compose.yml to the new tag and digest:

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

Leave Meilisearch alone. It is pinned to 1.41.0 on purpose: upstream names that as the version
Karakeep is built against and advises against upgrading it alone, because a newer engine refuses
an older index and the recovery is erasing `data.ms` and reindexing everything. After any
update, re-run step 7's health check.

## 10. What will probably go wrong

The machine will sleep in the middle of a batch and you will think the import broke. I pasted
twenty links, closed the lid, opened it an hour later and half of them were still bare URLs.
Nothing had failed: no container runs while the computer is asleep, so the queue stops where it
was and starts again on wake, one link at a time, with a browser that has to boot first. The
other half is memory: Docker Desktop gets a fixed slice of RAM on macOS and Windows, and three
containers with a Chrome in one will find the edge of a small one, after which the crawl worker
dies quietly and the card stays empty. Leave the machine awake for the first import, and raise
that limit before debugging.

## 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 8182 to 0.0.0.0 so a phone on the wifi can reach it. That publishes an app which
  fetches arbitrary URLs to every network this machine joins.
- Do not set `OPENAI_API_KEY` or `OLLAMA_BASE_URL`, and do not turn on
  `CRAWLER_FULL_PAGE_ARCHIVE`, `CRAWLER_STORE_PDF` or `CRAWLER_VIDEO_DOWNLOAD`. The first bills
  the user's card, the rest fill their disk.
compose.local.ymlthe services, pinned · local layout70 lines

authored from upstream docs, never pasted · 3,159 bytes

# Karakeep · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.karakeep.app/installation/docker
#   configuration ...... https://docs.karakeep.app/configuration/environment-variables
#   minimal install .... https://docs.karakeep.app/installation/minimal-install
#   image build ........ https://github.com/karakeep-app/karakeep/blob/v0.33.2/docker/Dockerfile
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/karakeep/, so one file works on macOS, Linux and Windows, and both
# stay bind mounts so you can open your archives in Finder or Explorer. Both
# images that write to disk run as root in their containers, so no chown is
# needed. `web` is the all-in-one image: app, workers and migration under s6,
# SQLite in /data. `chrome` renders and screenshots pages. `meilisearch` is the
# search engine, without which upstream says search is disabled completely,
# pinned to the 1.41.0 Karakeep is built against rather than the newer line on
# Meilisearch's own page here. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.41.0@sha256:860fa4baed04ae1c235de870edab0c8006227546dea1bbb6411fbfc5e27cf1db
    container_name: karakeep-meilisearch
    restart: unless-stopped
    environment:
      # Production mode refuses to start without a master key.
      MEILI_ENV: production
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      # The index. Rebuilt from the database, so step 8 leaves it out.
      - ./meili:/meili_data
    # No `ports:`: 7700 is reachable only from the web container.

  chrome:
    image: ghcr.io/karakeep-app/karakeep-chrome:151.0.7922.47-r1@sha256:5b19bbb160e9ff60681a3abd97e1c4ec9f64212301410de658c3900ab7ef31e7
    container_name: karakeep-chrome
    restart: unless-stopped
    init: true
    command:
      - --disable-gpu
      - --disable-dev-shm-usage
      - --hide-scrollbars
      - --disable-blink-features=AutomationControlled
      - --window-size=1440,900
    # No `ports:` and no volume: 9222 remote-controls a browser with no
    # credential, and only the web container is on this network.

  web:
    image: ghcr.io/karakeep-app/karakeep:0.33.2@sha256:b069e4307dec06ea06d16989c6861c30a1ff208568be44ed5fb5d422cd3e950c
    container_name: karakeep
    restart: unless-stopped
    env_file: ./.env
    environment:
      MEILI_ADDR: http://meilisearch:7700
      BROWSER_WEB_URL: http://chrome:9222
      # Upstream's compose says DON'T CHANGE THIS. The mount moves.
      DATA_DIR: /data
      # The image ships debug. `info` is the quietest level it defines.
      LOG_LEVEL: info
    volumes:
      # The SQLite database and every archived asset.
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8182.
      - "127.0.0.1:8182:3000"
    # Start ordering only: web connects to both lazily and retries.
    depends_on:
      - meilisearch
      - chrome

agent-readable mirror: /self-host/readwise.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned69 lines

authored from upstream docs, never pasted · 3,130 bytes

# Karakeep · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.karakeep.app/installation/docker
#   configuration ...... https://docs.karakeep.app/configuration/environment-variables
#   minimal install .... https://docs.karakeep.app/installation/minimal-install
#   image build ........ https://github.com/karakeep-app/karakeep/blob/v0.33.2/docker/Dockerfile
#
# Three services. `web` is upstream's all-in-one image: app, workers and the
# migration under s6, with SQLite in /data, so no Postgres appears here.
# `chrome` renders and screenshots pages; upstream says that without it
# javascript pages crawl badly. `meilisearch` is the search engine, without
# which upstream says search is disabled completely; it is pinned to the
# 1.41.0 Karakeep is built against, not the newer line on Meilisearch's own
# page here. MEILI_MASTER_KEY comes from the .env beside this file, so run
# compose from /srv/karakeep. Digests read 2026-08-14; all three ship arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  meilisearch:
    image: getmeili/meilisearch:v1.41.0@sha256:860fa4baed04ae1c235de870edab0c8006227546dea1bbb6411fbfc5e27cf1db
    container_name: karakeep-meilisearch
    restart: unless-stopped
    environment:
      # Production mode refuses to start without a master key.
      MEILI_ENV: production
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
    volumes:
      # The index. Rebuilt from the database, so step 8 leaves it out.
      - /srv/karakeep/meili:/meili_data
    # No `ports:`: 7700 is reachable only from the other containers.

  chrome:
    image: ghcr.io/karakeep-app/karakeep-chrome:151.0.7922.47-r1@sha256:5b19bbb160e9ff60681a3abd97e1c4ec9f64212301410de658c3900ab7ef31e7
    container_name: karakeep-chrome
    restart: unless-stopped
    init: true
    command:
      - --disable-gpu
      - --disable-dev-shm-usage
      - --hide-scrollbars
      - --disable-blink-features=AutomationControlled
      - --window-size=1440,900
    # No `ports:` and no volume: 9222 remote-controls a real browser with no
    # credential, and only the web container shares this network.

  web:
    image: ghcr.io/karakeep-app/karakeep:0.33.2@sha256:b069e4307dec06ea06d16989c6861c30a1ff208568be44ed5fb5d422cd3e950c
    container_name: karakeep
    restart: unless-stopped
    env_file: /srv/karakeep/.env
    environment:
      MEILI_ADDR: http://meilisearch:7700
      BROWSER_WEB_URL: http://chrome:9222
      # Upstream's compose says DON'T CHANGE THIS. The mount moves.
      DATA_DIR: /data
      # The image ships debug. `info` is the quietest level it defines.
      LOG_LEVEL: info
    volumes:
      # The SQLite database and every archived asset: the product.
      - /srv/karakeep/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8182.
      - "127.0.0.1:8182:3000"
    # Start ordering only: web connects to both lazily and retries.
    depends_on:
      - meilisearch
      - chrome
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,093 bytes

# Karakeep · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.karakeep.app/installation/docker 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. That hostname is
# also NEXTAUTH_URL in .env, https:// and no trailing slash: Caddy speaks plain
# http to the container, so that variable is what tells Karakeep it is https.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# One list can be public while the rest of the app sits behind a
		# login on the same hostname. SAMEORIGIN protects the second half.
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8182 is the loopback port compose publishes on this host: not a container
	# port, and not open in the firewall. Caddy sets no body limit, so an
	# upload's ceiling is MAX_ASSET_SIZE_MB, default 50.
	reverse_proxy 127.0.0.1:8182
}
install.shthe same install, no agent198 lines

authored from upstream docs, never pasted · 9,598 bytes

#!/usr/bin/env bash
# Karakeep · 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=keep.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.karakeep.app/installation/docker
#   https://docs.karakeep.app/configuration/environment-variables
#   https://docs.karakeep.app/installation/minimal-install
#   https://docs.karakeep.app/administration/security-considerations
#   https://docs.karakeep.app/administration/troubleshooting
#
# Two secrets are generated here, on this machine: NEXTAUTH_SECRET, which signs
# the session tokens, and MEILI_MASTER_KEY, which is the only credential the
# search engine accepts. Both go into /srv/karakeep/.env with mode 600 and
# neither is ever printed.
#
# This script cannot create your account, because only a browser can. It stops
# with registration still open and tells you to go and claim the instance.
# Karakeep gives the admin role to whoever registers while the users table is
# empty, so until you do that, this hostname is offering the administrator
# account to anyone who knows it. Claiming it and running the two closing
# commands in the summary below is the whole security story of this install.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/karakeep}"
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. keep.example.com"
case "$DOMAIN_HOST" in
	*/*) die "DOMAIN_HOST is a hostname, not a URL: no scheme and no trailing slash" ;;
esac
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"
command -v curl >/dev/null 2>&1 || die "curl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; the app, a headless Chrome and Meilisearch want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; archived pages and screenshots want 20 GB"

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

# --- 2. Lay the files out ----------------------------------------------------
#
# Two owners, on purpose. The Karakeep and Meilisearch images both run as root
# inside their containers and write to their mounts, so those two directories
# stay with root. Everything you touch by hand stays yours. Keep data/ on local
# disk: db.db is a SQLite file and a network mount corrupts one quietly.

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

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Upstream documents the generator below for both: base64 for NEXTAUTH_SECRET
# and alphanumerics only for MEILI_MASTER_KEY. Read them later with
#   sudo grep -E 'NEXTAUTH_SECRET|MEILI_MASTER_KEY' /srv/karakeep/.env
# Changing NEXTAUTH_SECRET signs everyone out. Changing MEILI_MASTER_KEY leaves
# an index the app can no longer open, and you reindex from the admin screens.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		NEXTAUTH_URL=https://${DOMAIN_HOST}
		DISABLE_SIGNUPS=false
		NEXTAUTH_SECRET=$(openssl rand -base64 36)
		MEILI_MASTER_KEY=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9')
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

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

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-karakeep"
	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. Ports: two open, and none of 8182, 7700 or 9222 is one of them -------

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

# --- 6. Start it -------------------------------------------------------------
#
# The web image runs the database migration, the Next.js app and the background
# workers together under s6, so the first boot writes the schema before it
# answers anything. The first pull is around a gigabyte across three images.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/health"
for _ in $(seq 1 36); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health")" || code="no-answer"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 40 web"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"status":"ok"' \
	|| die "/api/health answered 200 without status ok. Check: docker compose logs --tail 40 web"

# The sign-in page is the first screen. Its absence means Caddy is reaching
# something other than Karakeep.
curl -sSL "https://${DOMAIN_HOST}/signin" | grep -q 'Welcome Back' \
	|| die "https://${DOMAIN_HOST}/signin does not carry the sign-in heading"

# The REST API must refuse a call carrying no bearer token.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/bookmarks")" || unauth="no-answer"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."

# Meilisearch has no host port, and it still refuses an unauthenticated call
# from inside the compose network. This runs the check from the web container.
meili="$(docker compose exec -T web curl -sS -o /dev/null -w '%{http_code}' http://meilisearch:7700/indexes)" || meili="no-answer"
[ "$meili" = "401" ] || die "meilisearch answered ${meili} to an unauthenticated call, not 401. Check MEILI_MASTER_KEY in .env."

# Registration is open, and the summary below is about closing it. Confirming
# the form is there is the last check; claiming it is the first thing you do.
curl -sSL "https://${DOMAIN_HOST}/signup" | grep -q 'Create Your Account' \
	|| die "https://${DOMAIN_HOST}/signup is not offering the registration form; a user may already exist"

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped on purpose: a SQLite file copied mid-write is not a backup. Downtime
# is a few seconds. meili/ is not in the archive, because the search index is
# rebuilt from the database by Reindex All Bookmarks in the admin screens.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/karakeep-${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/karakeep-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

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

	  1. Do this now, before anything else, and do not walk away in the middle.
	     Open
	       https://${DOMAIN_HOST}/signup
	     and create your account. Whoever registers first becomes the
	     administrator of this instance. Then come straight back and shut the
	     door:

	       cd $APP_DIR
	       sed -i 's/^DISABLE_SIGNUPS=false\$/DISABLE_SIGNUPS=true/' .env
	       docker compose up -d --force-recreate --no-deps web
	       sleep 20
	       docker compose exec -T web printenv DISABLE_SIGNUPS
	       curl -sSL https://${DOMAIN_HOST}/signup | grep -c 'Create Your Account'

	     The last two lines must print \`true\` and \`0\`. A restart is not enough:
	     docker compose only re-reads .env when the container is recreated,
	     which is what --force-recreate is for.
	  2. The crawler drives a real browser from inside your network, and
	     upstream names limiting access to trusted users as the first
	     mitigation. That is the same thing as keeping signups shut. Add people
	     by opening the door for a minute, not by leaving it open.
	  3. AI tagging is off. It needs your own OpenAI key or your own Ollama
	     endpoint, and the bill is yours. Add OPENAI_API_KEY=... to
	     $APP_DIR/.env and recreate the web container to turn it on.
	     Everything else works without it.
	  4. Your two secrets are in $APP_DIR/.env, mode 600. Read them with
	       sudo grep -E 'NEXTAUTH_SECRET|MEILI_MASTER_KEY' $APP_DIR/.env
	     and put them in your password manager. Neither was printed here.
	  5. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Readwise.

  • You are running a browser that fetches whatever anyone with an account asks it to. Upstream says so plainly, and lists limiting access to trusted users as the first mitigation. That is why this install closes signups and asserts they are closed: an open instance is not only a squatter risk, it is a way for a stranger to make requests from inside your network.
  • The AI half of the pitch is a bill you own. Automatic tagging needs your own OpenAI key or your own Ollama box, and neither ships here. Upstream's own figure is tags for more than 6000 bookmarks for under a dollar, which is small but not zero and it lands on your card, every month, forever. Leave the key out and Karakeep still bookmarks, archives and full-text searches; it stops guessing tags.
  • Three containers, and search is a second engine you version-pin. Meilisearch holds the index, Karakeep is built against 1.41.0, and upstream tells you not to upgrade that engine on its own because a newer one refuses an older index. Meilisearch itself is MIT with a BUSL-1.1 enterprise carve-out, so the search engine inside this AGPL application is not all open source.
  • Archives grow, and faster than a bookmark list suggests. Every crawled link can leave a screenshot and a cached banner image behind by default, and full-page archives, PDF snapshots and video downloads are all switches you can turn on later. The disk floor here is about month three, not day one.
  • No daily review email, no Kindle or Apple Books highlight sync, no highlight export to Notion or Obsidian. Those are the specific things Readwise sells, and Karakeep does none of them. What you get instead is the capture-everything inbox and the archive underneath it.

Where this came from

“Any untrusted user can submit bookmarks to be crawled from your server and they'll be able to see the crawling result.”

  • DISABLE_SIGNUPS defaults to false, so a fresh instance accepts registrations from anyone who reaches it, and NEXTAUTH_SECRET is a required random string generated with openssl rand -base64 36. source
  • The account created when the users table is empty is given the admin role, so the first person to register on an open instance owns it. source
  • The crawler drives a real browser on your network, so upstream lists limiting access to trusted users as the first mitigation for the requests untrusted users can make it send. source
  • Without Meilisearch, search is disabled completely; without the Chrome container, crawling still works but loses screenshots and javascript-rendered pages; without an OpenAI or Ollama endpoint, AI tagging is skipped. source
  • Karakeep is built against Meilisearch 1.41.0 and upstream advises against upgrading that engine on its own, because a newer engine refuses an older index and the recovery is erasing data.ms and reindexing every bookmark. source
  • Automatic tagging bills to your own OpenAI key, and upstream's own estimate is tags for more than 6000 bookmarks for under a dollar on the default text model. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Readwise?

    Not Readwise itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Karakeep. One inbox for every link, note, image and PDF you meant to come back to, archived against link rot and searchable years later. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 100 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Readwise?

    Karakeep. One inbox for every link, note, image and PDF you meant to come back to, archived against link rot and searchable years later. The closest thing to the shape people actually use Readwise for: one inbox you throw everything into, from a phone share sheet, a browser extension or an RSS feed, and an archive underneath that keeps a readable copy when the page dies. It tags what you save with an AI model, so the pile stays findable without you filing anything, and it full-text searches links, notes, images and PDFs together. Two honest gaps. It does not sync Kindle or Apple Books highlights and it sends no daily review email, so the spaced-repetition half of Readwise is absent. And the AI tagging is your own OpenAI key or your own Ollama box, so a bill Readwise folded into the subscription becomes a separate small one you watch. It costs three containers, including a headless browser that fetches whatever anyone with an account asks it to, which is why the install closes signups and proves they are closed. Also evaluated and ranked below it: Linkwarden. The prompt on this page installs Karakeep only. Karakeep is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Readwise?

    4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Karakeep itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Readwise Full, $12.99/mo — $155.88 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Karakeep install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Karakeep on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Karakeep on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The iOS and Android apps and the share-sheet capture that make this a read-later tool need to reach the server, and http://localhost:8182 answers on this computer only, so on this path capture is the browser extension and this keyboard; queued crawls also stop while the machine sleeps and resume when it wakes. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.