Dockerized wrapper for Haven — a High Availability Vault for Events on Nostr. Haven is a sovereign personal relay that bundles four specialized relays (Private, Chat, Inbox, Outbox) plus a Blossom media server into one self-hosted package, with web-of-trust filtering, note importing, cloud backups, and blastr broadcasting built in.
This repository packages Haven as a Docker Compose setup with a TUI for configuration and management.
-
Ensure Docker and Docker Compose are installed and running.
-
Get this repository onto the host. The Compose files, the example configs and the
./havenCLI all live here, and every command below is run from inside the checkout:
git clone https://github.com/HolgerHatGarKeineNode/haven-docker.git
cd haven-dockerA clone is not strictly required — docker-compose.yml plus the config files is
a complete setup, see Without the CLI —
but the examples, the CLI and git pull for new image versions come with it.
- Copy the example files and edit them for your relay:
cp .env.example .env
cp relays_import.example.json relays_import.json
cp relays_blastr.example.json relays_blastr.json
cp blacklisted_npubs.example.json blacklisted_npubs.json
cp whitelisted_npubs.example.json whitelisted_npubs.jsonThe web dashboard templates are baked into the image — no host templates/ copy is required for a default install.
- Edit
.env— at minimum set these to your own values:
| Variable | Description |
|---|---|
OWNER_NPUB |
Your nostr public key (npub) |
RELAY_URL |
Public hostname of your relay |
RELAY_PORT |
Port the relay listens on (default 3355) |
PRIVATE_RELAY_NPUB |
npub for the private relay |
CHAT_RELAY_NPUB |
npub for the chat relay |
OUTBOX_RELAY_NPUB |
npub for the outbox relay |
INBOX_RELAY_NPUB |
npub for the inbox relay |
All relay names, descriptions, icons, rate limiters, WOT, backup, and import settings can also be configured in .env. See .env.example for the full list with comments.
- Edit the JSON lists to fit your needs:
relays_import.json— relays to import notes fromrelays_blastr.json— relays for blastr to broadcast toblacklisted_npubs.json— npubs to blockwhitelisted_npubs.json— npubs to allow (if whitelist mode)
- Start the relay:
./havenThe TUI guides you through the remaining setup. Prefer plain Compose? docker compose up -d works just as well — see
Without the CLI.
The container writes to the bind-mounted ./db and ./blossom directories and
runs as ${DOCKER_UID}:${DOCKER_GID} (default 1000:1000). If those directories
belong to a different uid, Haven fails to start with a permission error.
./haven start handles this for you: it creates the directories, writes your own
id -u / id -g into .env as DOCKER_UID / DOCKER_GID, and aborts with a
chown hint if the ownership still does not match.
If you run docker compose up directly, set both yourself before the first
start — otherwise Compose creates the directories as root and the container
cannot write to them:
printf 'DOCKER_UID=%s\nDOCKER_GID=%s\n' "$(id -u)" "$(id -g)" >> .envDo not work around this with chmod -R 777 db/ — that makes the relay
database world-writable. Fix the ownership instead:
sudo chown -R "$(id -u):$(id -g)" db blossomOwnership is all it takes: Compose creates a missing bind-mount source as
root:root with mode 0755, so as soon as the directory is yours, the owner
bits already grant read and write. No chmod is involved in the fix.
If you already ran chmod -R 777, chown does not undo it — ownership and
mode bits are independent, and the database stays world-writable until you reset
the bits yourself:
chmod -R u=rwX,go= db blossomCapital X sets the execute bit on directories only, so database files do not
end up executable. Nobody but your own user needs access here — the container
runs as ${DOCKER_UID}:${DOCKER_GID}, which is you. Use 750/640 instead if
you want the group to read as well.
To override the baked-in templates, copy the examples and re-enable the volume in docker-compose.yml (and docker-compose.tor.yml if you use Tor):
cp -r templates-example/* templates/volumes:
- "./templates:/app/templates" # uncomment this lineA host bind-mount replaces the image copy entirely — an empty ./templates directory will break the UI.
./haven start # Start (Docker Compose)
./haven start --tor # Start with Tor hidden service
./haven stop # Stop services
./haven restart # Restart services
./haven logs # Stream logs
./haven onion # Show Tor .onion address
./haven json # Edit JSON lists in TUI
./haven env-upgrade # Add missing vars from .env.example
./haven help # Full usage info./haven is convenience, not a requirement. It is a bash script on the host that
drives docker compose and edits .env for you; the image knows nothing about
it. docker-compose.yml, .env and the four JSON files are a complete setup,
and docker compose up -d is a supported way to run it.
| CLI | Plain Compose |
|---|---|
./haven start |
docker compose up -d |
./haven stop |
docker compose down |
./haven restart |
docker compose restart |
./haven logs |
docker compose logs -f |
./haven start --tor |
docker compose -f docker-compose.tor.yml up -d |
./haven import |
the three commands below |
docker compose stop relay
docker compose run --rm --no-deps -e HAVEN_IMPORT_FLAG=true relay
docker compose start relayrun ignores the service's restart policy — that is the whole point. Stopping
the relay first is not optional: badger and lmdb take an exclusive lock on db/,
and the import is a second writer.
Four things the CLI does for you and you take on yourself here:
-
DOCKER_UID/DOCKER_GID—./haven startwrites your ownid -u/id -ginto.env; Compose alone falls back to the1000:1000inuser: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}". Check whether that is you:id -u; id -g # who you are on the host docker exec haven-relay id # who the container writes as ls -ln db | head -3 # who owns the database files (numeric)
Same numbers, nothing to do. Different ones, set them once and recreate the container —
user:is part of its definition, sorestartkeeps the old uid:printf 'DOCKER_UID=%s\nDOCKER_GID=%s\n' "$(id -u)" "$(id -g)" >> .env sudo chown -R "$(id -u):$(id -g)" db blossom docker compose up -d
More on this in File ownership.
-
All config files exist before the first start — Compose creates a missing bind-mount source as a root-owned directory, so a forgotten
relays_import.jsoncomes back as a folder and the relay fails on it. -
Updates — the image tag in
docker-compose.ymlis pinned.docker compose pullwill not move it: change the tag yourself (orgit pullfor the current one), thendocker compose up -d. -
.envdrift —./haven startcompares your.envagainst the one upstream ships inside the image and names every variable you leave unset (each falls back to a built-in default). Without it, diff against.env.exampleafter an update.
The relay never imports on its own. Importing is a separate, one-shot run of the
same binary — filling in relays_import.json and the ## Import Settings in
.env configures it, but does not trigger it:
cd /path/to/haven-docker
./haven import # runs the import once, in the foregroundIt stops the relay first and starts it again afterwards — badger and lmdb both
take an exclusive lock on db/, so the import and the relay cannot both write.
Expect it to take a while. Your own notes end at ✅ owner note import complete!; the run then fetches the notes tagging you and finishes on
✅ tagged import complete.
Run it on the host, from this repository — not inside the container. Two
different programs are called haven: the ./haven here is this repo's CLI, a
shell script that drives Docker Compose, while /app/haven inside the image is
upstream's relay binary. docker exec haven-relay ./haven import reaches the
second one and opens db/ while the running relay still holds the lock, which
ends in Cannot acquire directory lock on "db/private". Since v1.2.2-4 the
image intercepts that call and points back here instead of panicking.
Driving Compose yourself instead of using the CLI? The same import in three commands: Without the CLI.
HAVEN_IMPORT_FLAG is what decides it — entrypoint.sh reads it and runs
haven import instead of the relay. Where it is set makes the difference:
- passed to a one-shot run (
docker compose run -e HAVEN_IMPORT_FLAG=true) is the correct path, and exactly what./haven importdoes under the hood.runignores the restart policy, so the container is gone once the import ends. - written into
.envis the trap. The import exits when it finishes andrestart: unless-stoppedstarts the container right back into it, so it imports in a loop and the relay never comes up../haven startwarns if it finds the flag set there.
What it fetches, from the relays in relays_import.json:
- your own notes, in 10-day windows from
IMPORT_START_DATEup to now, into the outbox relay - notes tagging you, into the inbox relay — gift-wrapped ones into the chat relay
Do not empty relays_import.json when the import is done. The running relay
reads it at every boot and needs it for three more things: the connectivity check
at startup, building the web of trust (follow lists are fetched from exactly
these relays), and the live inbox subscription. With an empty list the web of
trust shrinks to your whitelist and the inbox stops receiving anything.
After starting, ./haven start compares your .env against the .env.example
that upstream Haven ships inside the image and names any variable the relay
supports but your .env leaves unset — it then runs on its built-in default.
./haven env-upgrade adds the ones this repo documents.
The interactive TUI (./haven without arguments) understands:
| Key | Action |
|---|---|
↑ ↓ or k j |
Move the menu selection |
g / G, Home / End |
Jump to the first / last menu entry |
1–9 |
Select the nth menu entry directly (Enter opens it) |
Enter |
Open the selected entry |
← / Esc |
Go back one view |
q |
Back one view; from the main view, quit |
Tab |
Switch the right panel between the log view and the status view |
PgUp / PgDn |
Scroll the log panel backwards / forwards; a paused banner marks the scroll position and new lines keep buffering |
/ |
Live filter for the log panel (case-insensitive substring) — the match count shows on the first panel row, an empty Enter clears the filter |
Inside an input prompt:
| Key | Action |
|---|---|
← / → |
Move the cursor within the input |
Ctrl-A / Ctrl-E |
Jump to the start / end of the input |
Backspace / Delete |
Delete before / after the cursor |
Ctrl-U |
Clear the input |
Ctrl-W |
Delete the word before the cursor |
| Paste | Pasted text inserts at the cursor position |
Enter |
Accept; invalid values show an inline error and keep editing |
Esc / Ctrl-C |
Cancel with the previous value |
Every write to .env or the JSON lists (add, edit, remove, clear,
env-upgrade) first copies the file to a timestamped backup next to it —
.env.bak.20260920153000 — and keeps the 10 most recent backups. To undo
a change, copy the newest backup back:
ls -1t .env.bak.* | head -1 # find the newest backup
cp .env.bak.20260920153000 .env # restore itThe relay picks the restored values up on the next restart
(./haven restart).
Haven is built and maintained by barrydeen and its contributors. Thanks to everyone who makes this project possible.