Karakeep is a practical self-hosted read-it-later and knowledge capture system for teams that want bookmarks, archived pages, screenshots, and full text search without handing every internal link to a hosted SaaS account. A small operations team can use it to collect vendor documentation, incident writeups, research links, architecture references, and customer-specific evidence in one searchable place. This guide shows a production-oriented Ubuntu deployment with Docker Compose for repeatability, Caddy for automatic HTTPS, PostgreSQL for durable application data, Meilisearch for fast indexing, and a headless Chrome service for page crawling and screenshots.
The goal is not merely to make the container start. The goal is a maintainable service with a clear network boundary, named volumes, locked-down secrets, verification commands, backup routines, and troubleshooting paths you can hand to another administrator. Replace the example domain with your real DNS name before you run the commands.
Architecture and flow overview
The public request flow is intentionally simple: users browse to https://karakeep.example.com, Caddy terminates TLS, and Caddy proxies traffic to the Karakeep web container bound only on 127.0.0.1:3090. The application talks to PostgreSQL on the private Compose network for account and bookmark data, Meilisearch for search indexing, and Chrome for browser automation. No database, search, or browser port is exposed to the public internet.
This pattern mirrors the house style used in the recent Guides section: one host, explicit service boundaries, host-level Caddy, Docker Compose for application dependencies, and verification commands after every major step. It is easy to extend later with object storage, external PostgreSQL, or single sign-on, but the first version stays understandable.
Prerequisites
- Ubuntu 22.04 or 24.04 server with at least 2 vCPU, 4 GB RAM, and 30 GB free disk.
- A DNS record such as
karakeep.example.compointing to the server. - Docker Engine and the Docker Compose plugin installed.
- Root or sudo access, plus outbound HTTPS for image pulls and certificate issuance.
- Ports 80 and 443 open to the internet; internal application ports stay private.
Step-by-step deployment
1. Create the application directory and environment file
Keep Karakeep under /opt so service data, Compose files, and backups are easy to locate. The initial environment file uses placeholders; generate real secrets in the next section before the service is exposed.
mkdir -p /opt/karakeep/{data,postgres,meilisearch}
cd /opt/karakeep
umask 077
cat > .env <<'EOF'
KARAKEEP_VERSION=release
KARAKEEP_DOMAIN=karakeep.example.com
POSTGRES_PASSWORD=replace-with-a-long-random-password
MEILI_MASTER_KEY=replace-with-a-long-random-key
NEXTAUTH_SECRET=replace-with-openssl-rand-hex-32
NEXTAUTH_URL=https://karakeep.example.com
EOF
If the copy button is unavailable, select the code block manually and copy it.
2. Add the Docker Compose stack
The web service publishes only to localhost, which means Caddy can reach it but the port is not directly reachable from the internet. PostgreSQL and Meilisearch use bind-mounted directories so backup scripts can capture predictable paths. Chrome stays internal and is only used by Karakeep for page capture.
cat > docker-compose.yml <<'EOF'
services:
web:
image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION}
restart: unless-stopped
depends_on: [postgres, meilisearch, chrome]
environment:
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
DATABASE_URL: postgresql://karakeep:${POSTGRES_PASSWORD}@postgres:5432/karakeep
MEILI_ADDR: http://meilisearch:7700
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
BROWSER_WEB_URL: http://chrome:9222
DATA_DIR: /data
volumes:
- ./data:/data
ports:
- "127.0.0.1:3090:3000"
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: karakeep
POSTGRES_USER: karakeep
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
meilisearch:
image: getmeili/meilisearch:v1.8
restart: unless-stopped
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
MEILI_NO_ANALYTICS: "true"
volumes:
- ./meilisearch:/meili_data
chrome:
image: gcr.io/zenika-hub/alpine-chrome:with-node
restart: unless-stopped
command: ["chromium-browser", "--headless", "--no-sandbox", "--disable-gpu", "--remote-debugging-address=0.0.0.0", "--remote-debugging-port=9222"]
EOF
If the copy button is unavailable, select the code block manually and copy it.
3. Configure Caddy and the firewall
Caddy should be installed on the host rather than inside the Compose stack. That makes certificate renewal, logs, and reverse proxy reloads independent from application deployments.
sudo apt-get update
sudo apt-get install -y caddy ufw
sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
karakeep.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3090
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
If the copy button is unavailable, select the code block manually and copy it.
4. Start the stack
Pull the images, start the services, and inspect logs before sharing the URL with users. The first run may take a little longer while database tables and search indexes initialize.
cd /opt/karakeep
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=80 web
If the copy button is unavailable, select the code block manually and copy it.
Configuration and secrets handling best practices
Treat .env as a production secret file. It contains the database password, Meilisearch master key, and session signing secret. Generate high-entropy values, keep the file out of Git, and restrict read access to administrators and the Docker runtime group.
cd /opt/karakeep
python3 - <<'PY'
import secrets
for name in ["POSTGRES_PASSWORD", "MEILI_MASTER_KEY", "NEXTAUTH_SECRET"]:
print(f"{name}={secrets.token_urlsafe(48)}")
PY
# Paste the generated values into .env, then lock the file down:
sudo chown -R root:docker /opt/karakeep
sudo chmod 640 /opt/karakeep/.env
If the copy button is unavailable, select the code block manually and copy it.
For production teams, rotate credentials during scheduled maintenance and document who owns the recovery process. If you later move PostgreSQL or Meilisearch to managed services, keep the same environment-variable pattern but store the secret material in your normal password manager or secret store. Avoid embedding SMTP credentials, OAuth secrets, or API tokens directly in shell history; write them into the environment file with a root-owned editor.
Verification checklist
Verification should test each layer independently: public TLS, local reverse proxy target, database readiness, and search service health. Run these checks before onboarding users and after every upgrade.
curl -I https://karakeep.example.com
curl -sS http://127.0.0.1:3090/api/health || true
docker compose exec postgres pg_isready -U karakeep -d karakeep
docker compose exec meilisearch wget -qO- http://127.0.0.1:7700/health
If the copy button is unavailable, select the code block manually and copy it.
- The public URL should return a successful HTTP status and a valid certificate.
docker compose psshould show all services as running or healthy.- Creating a test bookmark should trigger indexing in Meilisearch and, for rich pages, Chrome capture activity.
- Restarting the stack with
docker compose restartshould preserve users, bookmarks, and search state.
Backups and recovery routine
Back up PostgreSQL first because it is the source of truth for application data. Then capture uploaded data and the Meilisearch directory. Search can often be rebuilt, but keeping the directory shortens recovery time for larger collections. Store backups off-host and test restoration on a staging machine at least once per quarter.
cat > /usr/local/sbin/backup-karakeep <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/karakeep
stamp=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p /var/backups/karakeep
source .env
docker compose exec -T postgres pg_dump -U karakeep karakeep | gzip > /var/backups/karakeep/postgres-$stamp.sql.gz
tar -C /opt -czf /var/backups/karakeep/files-$stamp.tgz karakeep/data karakeep/meilisearch
find /var/backups/karakeep -type f -mtime +14 -delete
EOF
chmod +x /usr/local/sbin/backup-karakeep
/usr/local/sbin/backup-karakeep
If the copy button is unavailable, select the code block manually and copy it.
A useful restore drill is to provision a clean VM, copy the latest SQL dump and data archive, restore the database, unpack the file directories, then start the same Compose file with a staging hostname. This proves that the runbook works without risking the live service.
Common issues and fixes
Caddy shows 502 Bad Gateway
Confirm the web container is publishing 127.0.0.1:3090:3000 and that Caddy points to 127.0.0.1:3090. If the Compose file only uses expose, host-level Caddy cannot reach the container.
Bookmarks save but search results are empty
Check the Meilisearch logs and confirm the same MEILI_MASTER_KEY is available to both the application and the search service. A key mismatch commonly looks like an indexing problem.
Page screenshots or rich previews fail
Inspect the Chrome container. It must listen on the private Compose network and the app must use the matching browser URL. Some sites block headless browsers, so test several pages before assuming the crawler is broken.
Login sessions reset after every restart
Set a stable NEXTAUTH_SECRET. If this value changes between deployments, existing sessions can become invalid and users will need to sign in again.
# Check the reverse proxy path first.
curl -vk https://karakeep.example.com 2>&1 | tail -40
# Then inspect each service in dependency order.
cd /opt/karakeep
docker compose logs --tail=120 postgres
docker compose logs --tail=120 meilisearch
docker compose logs --tail=120 chrome
docker compose logs --tail=160 web
If the copy button is unavailable, select the code block manually and copy it.
FAQ
Can I run Karakeep without exposing port 3090?
Yes. In this guide the port is bound to 127.0.0.1, so only local processes such as Caddy can reach it. Public users access the service through HTTPS on ports 80 and 443.
Should PostgreSQL be managed externally?
For a small team, the local container is usually fine if backups are tested. For larger teams or strict uptime requirements, managed PostgreSQL can reduce operational risk and simplify point-in-time recovery.
How much memory does Meilisearch need?
It depends on the size of your bookmark archive and indexed page content. Start with 4 GB RAM on the host and monitor usage. If indexing grows, move search to a larger server or tune retention policies.
Is Caddy required?
No, but Caddy keeps TLS and reverse proxy configuration concise. NGINX or Traefik are also valid if your organization already standardizes on them.
How do I upgrade safely?
Take a fresh database and data backup, read the release notes, pull the new image, then run docker compose up -d. Verify login, bookmark creation, search, and page capture before ending the maintenance window.
Can I connect this to SSO later?
Yes, if your selected Karakeep version and identity provider support the needed OAuth or OIDC settings. Add SSO after the base service is stable, and keep a break-glass administrator account documented.
What should be monitored?
Monitor disk usage, backup age, container restarts, HTTP availability, database readiness, and error spikes in the web logs. Search and Chrome failures are usually visible as degraded bookmark enrichment rather than total downtime.
Internal links
- Deploy Linkwarden with Docker Compose + Caddy + PostgreSQL
- Deploy Meilisearch with Docker Compose + Caddy + Master Key
- Deploy Joplin Server with Docker Compose + Caddy + PostgreSQL
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.
Header image: Unsplash, no watermark.