Self-hosting
To run Karet on your own hardware, pull the prebuilt container images from GitHub Container Registry and start them with Docker Compose.
Prerequisites
- Docker with the Compose plugin (
docker compose), or any other Compose-speaking runtime (Finch, Podman withpodman-compose). - A working directory; the compose file creates Docker volumes for Postgres, the S3 emulator's data and the Valkey queue.
- About 3 GB of free disk for the five images, plus your data.
The published images are:
| Image | Source |
|---|---|
ghcr.io/karet-org/karet:latest | karet-org/karet, Next.js UI |
ghcr.io/karet-org/karet-worker:latest | karet-org/karet-worker, Rust/Axum pipeline worker |
postgres:17-alpine | Upstream Postgres, accounts, configs and job history |
rustfs/rustfs:latest | Upstream RustFS S3-compatible object store |
valkey/valkey:8-alpine | Upstream Valkey, job queue and live state |
:latest tracks the default branch of each repo. Pin versioned tags for reproducible deploys. The web app and the worker are versioned separately; for example, web 0.10.1 runs with worker 0.7.0.
1. Drop in the compose file
Save this as compose.yml in an empty directory (it's the same file shipped at the root of the karet repo):
name: karet
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-karet}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD (e.g. openssl rand -hex 24)}
POSTGRES_DB: ${POSTGRES_DB:-karet}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-karet} -d ${POSTGRES_DB:-karet}"]
interval: 5s
timeout: 3s
retries: 20
valkey:
image: valkey/valkey:8-alpine
restart: unless-stopped
# AOF with everysec fsync: at most ~1s of queue/debounce state lost on
# crash. Job history is durable in S3 regardless.
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--maxclients", "1024"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
rustfs:
image: rustfs/rustfs:latest
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
RUSTFS_VOLUMES: /data
RUSTFS_ADDRESS: 0.0.0.0:9000
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CONSOLE_ENABLE: "true"
RUSTFS_ACCESS_KEY: ${AWS_ACCESS_KEY_ID:-rustfsadmin}
RUSTFS_SECRET_KEY: ${AWS_SECRET_ACCESS_KEY:-rustfsadmin}
RUSTFS_CORS_ALLOWED_ORIGINS: "*"
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*"
RUSTFS_NOTIFY_ENABLE: "true"
RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY: "on"
# Upload events go to the worker, which owns debounce + enqueue.
# The secret rides in a header, not the URL.
RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY: "http://worker:8080/events/s3"
RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY: ${KARET_WEBHOOK_SECRET:?set KARET_WEBHOOK_SECRET (e.g. openssl rand -hex 32)}
RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY: /tmp/rustfs-events
# RustFS only makes outbound requests to allow-listed origins.
RUSTFS_OUTBOUND_ALLOW_ORIGINS: "http://worker:8080"
volumes:
- rustfs-data:/data
worker:
image: ghcr.io/karet-org/karet-worker:latest
restart: unless-stopped
environment:
PORT: "8080"
S3_BUCKET_PIPELINES: ${S3_BUCKET_PIPELINES:-karet-pipelines}
S3_BUCKET_LAKE: ${S3_BUCKET_LAKE:-karet-lake}
S3_BUCKET_WAREHOUSE: ${S3_BUCKET_WAREHOUSE:-karet-warehouse}
AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-http://rustfs:9000}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-rustfsadmin}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-rustfsadmin}
AWS_REGION: ${AWS_REGION:-us-east-1}
KARET_WORKER_TOKEN: ${KARET_WORKER_TOKEN:?set KARET_WORKER_TOKEN (e.g. openssl rand -hex 32)}
REDIS_URL: redis://valkey:6379
KARET_WEBHOOK_SECRET: ${KARET_WEBHOOK_SECRET:?set KARET_WEBHOOK_SECRET (e.g. openssl rand -hex 32)}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
DATABASE_URL: ${DATABASE_URL:-postgres://karet:${POSTGRES_PASSWORD}@postgres:5432/karet}
depends_on:
rustfs:
condition: service_started
valkey:
condition: service_healthy
postgres:
condition: service_healthy
web:
image: ghcr.io/karet-org/karet:latest
restart: unless-stopped
ports:
- "3000:3000"
environment:
PORT: "3000"
S3_BUCKET_PIPELINES: ${S3_BUCKET_PIPELINES:-karet-pipelines}
S3_BUCKET_LAKE: ${S3_BUCKET_LAKE:-karet-lake}
S3_BUCKET_WAREHOUSE: ${S3_BUCKET_WAREHOUSE:-karet-warehouse}
AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-http://rustfs:9000}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-rustfsadmin}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-rustfsadmin}
AWS_REGION: ${AWS_REGION:-us-east-1}
S3_FORCE_PATH_STYLE: "true"
KARET_SESSION_SECRET: ${KARET_SESSION_SECRET:?set KARET_SESSION_SECRET (e.g. openssl rand -base64 48)}
KARET_ADMIN_USERNAME: ${KARET_ADMIN_USERNAME:-admin}
KARET_ADMIN_PASSWORD_HASH: ${KARET_ADMIN_PASSWORD_HASH:?generate with `npm run hash-password`}
KARET_WORKER_TOKEN: ${KARET_WORKER_TOKEN:?set KARET_WORKER_TOKEN (e.g. openssl rand -hex 32)}
REDIS_URL: redis://valkey:6379
DATABASE_URL: ${DATABASE_URL:-postgres://karet:${POSTGRES_PASSWORD}@postgres:5432/karet}
KARET_PUBLIC_URL: ${KARET_PUBLIC_URL:-http://localhost:3000}
depends_on:
rustfs:
condition: service_started
worker:
condition: service_started
valkey:
condition: service_healthy
postgres:
condition: service_healthy
volumes:
rustfs-data:
valkey-data:
postgres-data:The worker and Valkey ports are not exposed to the host. They are only reachable over the compose network.
2. Generate the secrets
Karet refuses to start without five secrets. Four are random strings; the fifth is your admin password, hashed:
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
KARET_SESSION_SECRET=$(openssl rand -base64 48)
KARET_WORKER_TOKEN=$(openssl rand -hex 32)
KARET_WEBHOOK_SECRET=$(openssl rand -hex 32)
EOFPOSTGRES_PASSWORD is what the bundled Postgres starts with and what the default DATABASE_URL uses to reach it, so changing it later means changing both.
For the admin password hash, run npm run hash-password in a checkout of the karet repo. It prompts for a password (≥ 8 characters) and prints a ready-to-paste KARET_ADMIN_PASSWORD_HASH=... line for .env. See Authentication for why compose needs the $$-escaped variant.
3. Start the stack
docker compose up -dFirst boot downloads about 720 MB of images.
4. Create the buckets
Karet stores data in three S3 buckets and does not create them for you. With the stack up, create them once against RustFS. The defaults are karet-pipelines, karet-lake, and karet-warehouse (override with the S3_BUCKET_* variables in the reference below).
The AWS CLI reads credentials from the environment. Use the same keys RustFS was started with (defaults rustfsadmin / rustfsadmin):
export AWS_ACCESS_KEY_ID=rustfsadmin
export AWS_SECRET_ACCESS_KEY=rustfsadmin
for b in karet-pipelines karet-lake karet-warehouse; do
aws --endpoint-url http://localhost:9000 --region us-east-1 \
s3api create-bucket --bucket "$b"
doneNo CLI handy? Create the three buckets from the RustFS console at http://localhost:9001 instead.
Now open http://localhost:3000 and sign in with the password you hashed in step 2.
To wire up auto-runs (uploads triggering pipeline jobs), one more one-time step is needed: see Auto-runs.
5. Tail the logs (optional)
docker compose logs -f web workerOnce the worker is up you'll see queue enabled: consumer=worker-…, and the web service prints ▲ Next.js 15.x ✓ Ready in …ms.
Configuration reference
All services share environment variables for S3 access. The defaults (rustfsadmin / rustfsadmin, buckets karet-pipelines / karet-lake / karet-warehouse) are fine for a single-node homelab; rotate them if your network is shared.
| Variable | Default | Description |
|---|---|---|
S3_BUCKET_PIPELINES | karet-pipelines | Bucket for dashboards and saved queries. |
S3_BUCKET_LAKE | karet-lake | Bucket for raw source files. |
S3_BUCKET_WAREHOUSE | karet-warehouse | Bucket for partitioned Parquet output. |
AWS_ENDPOINT_URL | http://rustfs:9000 | S3 endpoint URL. Set to https://s3.<region>.amazonaws.com to swap out RustFS for real AWS. |
AWS_ACCESS_KEY_ID | rustfsadmin | S3 access key. |
AWS_SECRET_ACCESS_KEY | rustfsadmin | S3 secret key. |
AWS_REGION | us-east-1 | AWS region. |
REDIS_URL | redis://valkey:6379 | Valkey/Redis connection string (web + worker). |
DATABASE_URL | (required) | Postgres connection string (web + worker). Accounts, pipelines, config versions, job history, access. |
POSTGRES_PASSWORD | (required) | Password for the bundled Postgres, which the default DATABASE_URL interpolates. |
POSTGRES_USER / POSTGRES_DB | karet | Role and database name for that Postgres. |
KARET_SESSION_SECRET | (required) | Signs session cookies. openssl rand -base64 48. |
KARET_ADMIN_PASSWORD_HASH | (required) | scrypt hash of the bootstrap admin's password, re-asserted on every start (npm run hash-password). |
KARET_ADMIN_USERNAME | admin | Username of that bootstrap admin. |
KARET_WORKER_TOKEN | (required) | Bearer token the web service sends on worker /config/validate calls, and that machine callers send to the web API. |
KARET_WEBHOOK_SECRET | (required) | Shared secret RustFS sends with upload events; verified by the worker. |
KARET_PUBLIC_URL | http://localhost:3000 | The URL people reach this instance on. An https: value turns on secure cookies, so set it when you put Karet behind a proxy. |
S3_FORCE_PATH_STYLE | unset | true for S3 implementations without virtual-host addressing, RustFS included. |
WORKER_CONCURRENCY | 1 | Jobs processed concurrently per worker. |
DUCKDB_MEMORY_LIMIT | 512MB | Memory cap for the web service's DuckDB session. |
DUCKDB_THREADS | 2 | Thread cap for that session. |
DATABASE_POOL_MAX | web: 8 app + 4 auth; worker: 4 | Postgres connections per pool. On the web service it caps both of its pools. |
Updating
docker compose pull
docker compose up -dThe postgres-data, rustfs-data and valkey-data volumes persist across restarts and pulls, so your accounts, pipelines, dashboards, job history and queued jobs all survive an update. See backups for what to copy. The web image runs scripts/db-setup.mjs before it serves, so the schema migrates itself; several containers starting together is safe, because the first takes an advisory lock and the rest find nothing to do.
To wipe state and start over:
docker compose down -vSwitching off the bundled S3 emulator
For deployments backed by real AWS S3 instead of RustFS, drop the rustfs service from the compose file and point the web and worker at your buckets:
- Set
AWS_ENDPOINT_URLto the regional S3 endpoint (https://s3.<region>.amazonaws.com) on both services. - Set
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_REGIONto credentials that can read/write the buckets. - Set
S3_BUCKET_PIPELINES/S3_BUCKET_LAKE/S3_BUCKET_WAREHOUSEto your bucket names. - Drop
S3_FORCE_PATH_STYLEfrom the web service, real S3 prefers virtual-hosted addressing. - Auto-runs need an event source that can POST to the worker's
/events/s3; on AWS, wire S3 Event Notifications through a small forwarder (Lambda) or skip webhooks and trigger runs manually.
Keep the valkey service (or point REDIS_URL at a managed Redis-compatible endpoint such as ElastiCache for Valkey).
What's next?
- Getting started, build a Spending Tracker pipeline end to end.
- Architecture, the services and how they connect.
- Authentication, env-based credential and password rotation.