#!/bin/sh # DreyVault installer: sets up a self-hosted DreyVault server with Docker Compose. # # curl -fsSL https://dreyvault.abhishekm.work/install.sh | sh # sh install.sh [--yes] [--dir DIR] [--no-start] # # Safe to re-run: existing secrets and answers in DIR/.env are kept, then the stack is updated. # Non-interactive (--yes) reads answers from the environment: # DV_DOMAIN, DV_HTTPS (cloudflare|tailscale|caddy|lan), DV_SIGNUP (closed|invite|open), # DV_TUNNEL_TOKEN, DV_TS_AUTHKEY, DV_TS_HOSTNAME, DV_TS_TAILNET, DV_ACME_EMAIL, DV_LAN_IP, # DREYVAULT_IMAGE (default ghcr.io/dreyfvr/dreyvault:2), DREYVAULT_DIR (default ~/dreyvault), # DREYVAULT_TEMPLATES_URL (where compose/*.yml are fetched when not run from a checkout). # It never uses sudo. If Docker is missing it says how to install it and stops. set -eu DV_DIR="${DREYVAULT_DIR:-$HOME/dreyvault}" TEMPLATES_URL="${DREYVAULT_TEMPLATES_URL:-https://dreyvault.abhishekm.work/compose}" ASSUME_YES=0 START=1 while [ $# -gt 0 ]; do case "$1" in -y|--yes) ASSUME_YES=1 ;; --dir) shift; [ $# -gt 0 ] || { echo "--dir needs a value" >&2; exit 2; }; DV_DIR=$1 ;; --no-start) START=0 ;; -h|--help) sed -n '2,15p' "$0" 2>/dev/null || true; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac shift done say() { printf '%s\n' "$*"; } warn() { printf 'Warning: %s\n' "$*" >&2; } die() { printf 'Error: %s\n' "$*" >&2; exit 1; } # Prompts read from the terminal so `curl … | sh` still works. if [ "$ASSUME_YES" = 0 ]; then if ! { : /dev/null; then die "no terminal for questions. Re-run with --yes and DV_* variables (see the header of this script)." fi fi # ask VAR "Question" default → sets VAR (keeps a value already set in VAR as the default) ask() { _var=$1; _q=$2; _def=$3 eval "_cur=\${$_var:-}" [ -n "$_cur" ] && _def=$_cur if [ "$ASSUME_YES" = 1 ]; then eval "$_var=\$_def"; return 0; fi if [ -n "$_def" ]; then printf '%s [%s]: ' "$_q" "$_def" >/dev/tty; else printf '%s: ' "$_q" >/dev/tty; fi IFS= read -r _ans /dev/tty; else printf '%s: ' "$_q" >/dev/tty; fi stty -echo /dev/null || true IFS= read -r _ans /dev/null || true printf '\n' >/dev/tty [ -n "$_ans" ] && eval "$_var=\$_ans" return 0 } # choose VAR "Question" default option... → numbered menu, stores the option word choose() { _var=$1; _q=$2; _def=$3; shift 3 eval "_cur=\${$_var:-}" [ -n "$_cur" ] && _def=$_cur if [ "$ASSUME_YES" = 1 ]; then eval "$_var=\$_def"; return 0; fi while :; do say "$_q" >/dev/tty _i=1; _defn=1 for _o in "$@"; do _label=$(option_label "$_o") say " $_i) $_label" >/dev/tty [ "$_o" = "$_def" ] && _defn=$_i _i=$((_i + 1)) done printf 'Choose 1-%s [%s]: ' "$#" "$_defn" >/dev/tty IFS= read -r _ans /dev/tty done } option_label() { case "$1" in cloudflare) echo "Cloudflare Tunnel (no open ports; needs a free Cloudflare account and a domain on Cloudflare)" ;; tailscale) echo "Tailscale Serve (private to your tailnet; https://..ts.net)" ;; caddy) echo "Caddy with Let's Encrypt (public server; DNS points here, ports 80/443 open)" ;; lan) echo "Home network only (HTTPS with a local certificate; nothing exposed to the internet)" ;; invite) echo "Invite only: the admin creates invite links (recommended)" ;; closed) echo "Closed: only the first account (the admin)" ;; open) echo "Open: anyone who can reach the server can sign up" ;; *) echo "$1" ;; esac } env_get() { # env_get KEY → value from an existing .env (last wins), empty if missing [ -f "$DV_DIR/.env" ] || return 0 sed -n "s/^$1=//p" "$DV_DIR/.env" | tail -n 1 } random_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 else od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; fi } fetch() { # fetch URL DEST if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1" else die "need curl or wget to download $1"; fi } # ---------------------------------------------------------------- Docker docker_help() { say "" say "DreyVault runs in Docker. Install it first, then re-run this installer:" case "$(uname -s)" in Darwin) say " macOS: Docker Desktop (https://docs.docker.com/desktop/setup/install/mac-install/) or OrbStack (https://orbstack.dev)" ;; Linux) say " Linux: https://docs.docker.com/engine/install/ (or: curl -fsSL https://get.docker.com | sh, as root)" say " Then let your user run Docker: sudo usermod -aG docker \"\$USER\" and log in again." ;; *) say " https://docs.docker.com/get-docker/" ;; esac } command -v docker >/dev/null 2>&1 || { docker_help; exit 1; } if docker compose version >/dev/null 2>&1; then : else say "Docker Compose v2 (the 'docker compose' plugin) is missing." say " https://docs.docker.com/compose/install/" exit 1 fi if ! docker info >/dev/null 2>&1; then say "Docker is installed but not reachable. Start Docker (Docker Desktop / 'systemctl start docker')," say "or add your user to the 'docker' group, then re-run this installer." exit 1 fi # ---------------------------------------------------------------- Answers mkdir -p "$DV_DIR" chmod 700 "$DV_DIR" 2>/dev/null || true say "" say "DreyVault installer → $DV_DIR" [ -f "$DV_DIR/.env" ] && say "Found an existing install; press Enter to keep each current answer." say "" # Answers: environment (DV_*) > existing .env > defaults. HTTPS=${DV_HTTPS:-$(env_get DV_HTTPS)} DOMAIN=${DV_DOMAIN:-$(env_get RP_ID)} SIGNUP=${DV_SIGNUP:-$(env_get SIGNUP)} TUNNEL_TOKEN=${DV_TUNNEL_TOKEN:-$(env_get TUNNEL_TOKEN)} TS_AUTHKEY=${DV_TS_AUTHKEY:-$(env_get TS_AUTHKEY)} TS_HOSTNAME=${DV_TS_HOSTNAME:-$(env_get TS_HOSTNAME)} TS_TAILNET=${DV_TS_TAILNET:-$(env_get TS_TAILNET)} ACME_EMAIL=${DV_ACME_EMAIL:-$(env_get ACME_EMAIL)} LAN_IP=${DV_LAN_IP:-$(env_get LAN_IP)} IMAGE=${DREYVAULT_IMAGE:-$(env_get DREYVAULT_IMAGE)} IMAGE=${IMAGE:-ghcr.io/dreyfvr/dreyvault:2} EDITION=$(env_get EDITION); EDITION=${EDITION:-multi} SERVER_SECRET=$(env_get SERVER_SECRET) choose HTTPS "How should devices reach your vault over HTTPS?" cloudflare cloudflare tailscale caddy lan case "$HTTPS" in cloudflare) ask DOMAIN "Domain for the vault (on Cloudflare), e.g. vault.example.com" "" say "Create a named tunnel: Cloudflare dashboard → Zero Trust → Networks → Tunnels → Create (Cloudflared)." say "Copy the token from the 'docker run' command it shows." ask_secret TUNNEL_TOKEN "Tunnel token" [ -n "$TUNNEL_TOKEN" ] || die "a tunnel token is required (DV_TUNNEL_TOKEN with --yes)" ;; tailscale) say "Needs MagicDNS and HTTPS certificates enabled (Tailscale admin → DNS)." ask TS_HOSTNAME "Machine name in your tailnet" "dreyvault" ask TS_TAILNET "Your tailnet DNS name, e.g. tail1234.ts.net" "" [ -n "$TS_TAILNET" ] || die "the tailnet DNS name is required (DV_TS_TAILNET with --yes)" say "Create an auth key: Tailscale admin → Settings → Keys → Generate auth key." ask_secret TS_AUTHKEY "Tailscale auth key (only needed the first time)" DOMAIN="$TS_HOSTNAME.$TS_TAILNET" ;; caddy) ask DOMAIN "Domain for the vault (its DNS must point at this server), e.g. vault.example.com" "" ask ACME_EMAIL "Email for Let's Encrypt expiry notices (optional)" "" ;; lan) _host=$(hostname 2>/dev/null | sed 's/\.local$//' | tr '[:upper:]' '[:lower:]') ask DOMAIN "Hostname devices on your network use, e.g. ${_host:-dreyvault}.local" "${_host:-dreyvault}.local" ask LAN_IP "This machine's LAN IP (optional, also serves https://)" "" ;; *) die "unknown HTTPS method '$HTTPS' (cloudflare|tailscale|caddy|lan)" ;; esac DOMAIN=$(printf '%s' "$DOMAIN" | sed -e 's#^https\{0,1\}://##' -e 's#/.*$##' | tr '[:upper:]' '[:lower:]') [ -n "$DOMAIN" ] || die "a domain is required (DV_DOMAIN with --yes)" case "$DOMAIN" in *[!a-z0-9.-]*) die "'$DOMAIN' is not a valid hostname" ;; esac choose SIGNUP "Who may create accounts? (The first account is always the admin.)" invite invite closed open [ -n "$SERVER_SECRET" ] || SERVER_SECRET=$(random_hex) ORIGIN="https://$DOMAIN" # ---------------------------------------------------------------- Templates SCRIPT_DIR="" case "$0" in */*) SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || SCRIPT_DIR="" ;; esac mkdir -p "$DV_DIR/compose" for f in base.yml "$HTTPS.yml" tailscale-serve.json; do if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/compose/$f" ]; then cp "$SCRIPT_DIR/compose/$f" "$DV_DIR/compose/$f" else fetch "$TEMPLATES_URL/$f" "$DV_DIR/compose/$f.tmp" || die "could not download $TEMPLATES_URL/$f" mv "$DV_DIR/compose/$f.tmp" "$DV_DIR/compose/$f" fi done # ---------------------------------------------------------------- .env umask 077 { say "# DreyVault settings, written by install.sh. Keep this file private and backed up:" say "# SERVER_SECRET protects server-side secrets; losing it signs everyone out and breaks stored admin secrets." say "DREYVAULT_IMAGE=$IMAGE" say "DV_HTTPS=$HTTPS" say "SERVER_SECRET=$SERVER_SECRET" say "RP_ID=$DOMAIN" say "RP_ORIGIN=$ORIGIN" say "PUBLIC_URL=$ORIGIN" say "EDITION=$EDITION" say "SIGNUP=$SIGNUP" case "$HTTPS" in cloudflare) say "TUNNEL_TOKEN=$TUNNEL_TOKEN" ;; tailscale) say "TS_HOSTNAME=$TS_HOSTNAME"; say "TS_TAILNET=$TS_TAILNET"; say "TS_AUTHKEY=$TS_AUTHKEY" ;; caddy) say "ACME_EMAIL=$ACME_EMAIL" ;; lan) say "LAN_IP=$LAN_IP" ;; esac } > "$DV_DIR/.env.tmp" mv "$DV_DIR/.env.tmp" "$DV_DIR/.env" umask 022 # ---------------------------------------------------------------- Proxy config RESTART_PROXY=0 case "$HTTPS" in caddy) { if [ -n "$ACME_EMAIL" ]; then say "{"; say " email $ACME_EMAIL"; say "}"; say ""; fi say "$DOMAIN {" say " encode zstd gzip" say " reverse_proxy dreyvault:3000" say "}" } > "$DV_DIR/Caddyfile.tmp" ;; lan) _sites="$DOMAIN" [ -n "$LAN_IP" ] && _sites="$DOMAIN, $LAN_IP" { # Browsers send no SNI for https://; default_sni picks the IP certificate for them. if [ -n "$LAN_IP" ]; then say "{"; say " default_sni $LAN_IP"; say "}"; say ""; fi say "$_sites {" say " tls internal" say " encode zstd gzip" say " reverse_proxy dreyvault:3000" say "}" } > "$DV_DIR/Caddyfile.tmp" ;; tailscale) cp "$DV_DIR/compose/tailscale-serve.json" "$DV_DIR/tailscale-serve.json" ;; esac if [ -f "$DV_DIR/Caddyfile.tmp" ]; then if [ -f "$DV_DIR/Caddyfile" ] && ! cmp -s "$DV_DIR/Caddyfile.tmp" "$DV_DIR/Caddyfile"; then RESTART_PROXY=1; fi mv "$DV_DIR/Caddyfile.tmp" "$DV_DIR/Caddyfile" fi # ---------------------------------------------------------------- docker-compose.yml if [ -f "$DV_DIR/docker-compose.yml" ] && ! head -n 1 "$DV_DIR/docker-compose.yml" | grep -q 'Generated by DreyVault install.sh'; then cp "$DV_DIR/docker-compose.yml" "$DV_DIR/docker-compose.yml.bak" warn "your previous docker-compose.yml was saved as docker-compose.yml.bak" fi { say "# Generated by DreyVault install.sh from compose/base.yml + compose/$HTTPS.yml. Re-run the installer to change it." docker compose --project-directory "$DV_DIR" -f "$DV_DIR/compose/base.yml" -f "$DV_DIR/compose/$HTTPS.yml" config --no-interpolate } > "$DV_DIR/docker-compose.yml.tmp" || die "could not render docker-compose.yml" mv "$DV_DIR/docker-compose.yml.tmp" "$DV_DIR/docker-compose.yml" say "" say "Wrote $DV_DIR/.env and $DV_DIR/docker-compose.yml" if [ "$START" = 0 ]; then say "Not starting (--no-start). Start it with: cd \"$DV_DIR\" && docker compose up -d" exit 0 fi # ---------------------------------------------------------------- Start cd "$DV_DIR" case "$IMAGE" in */*) docker compose pull ;; *) docker compose pull --ignore-pull-failures --quiet 2>/dev/null || true ;; # local image tag esac docker compose up -d --remove-orphans [ "$RESTART_PROXY" = 1 ] && docker compose restart caddy say "Waiting for DreyVault to become healthy..." _status="" _i=0 while [ $_i -lt 60 ]; do _cid=$(docker compose ps -q dreyvault 2>/dev/null || true) if [ -n "$_cid" ]; then _status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$_cid" 2>/dev/null || true) fi [ "$_status" = "healthy" ] && break _i=$((_i + 1)) sleep 2 done if [ "$_status" != "healthy" ]; then warn "the server is not healthy yet (status: ${_status:-unknown}). Check: cd \"$DV_DIR\" && docker compose logs dreyvault" fi # ---------------------------------------------------------------- Next steps say "" say "DreyVault is running: $ORIGIN" say "" case "$HTTPS" in cloudflare) say "Finish the tunnel: Cloudflare dashboard → your tunnel → Public Hostname → Add:" say " hostname $DOMAIN service http://dreyvault:3000" ;; tailscale) say "Reachable from devices signed in to your tailnet. The first HTTPS request may take a few seconds (certificate)." ;; caddy) say "Caddy fetches a Let's Encrypt certificate on first visit. DNS for $DOMAIN must point here and ports 80/443 must be open." ;; lan) say "Each device must trust the local certificate once. Export it with:" say " cd \"$DV_DIR\" && docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./dreyvault-root.crt" say "and install dreyvault-root.crt as a trusted root (System Settings / Keychain, Android security settings)." say "$DOMAIN must resolve on your network (mDNS .local names work on most devices, or add it to your router's DNS)." ;; esac say "" say "Next steps:" say " 1. Open $ORIGIN and create your account. The first account becomes the admin." say " It needs the one-time setup token from the log: cd \"$DV_DIR\" && docker compose logs dreyvault | grep setup" say " 2. Admin → Backups: set a backup passphrase and store it somewhere safe (without it backups can't be restored)." say " 3. Install the browser extension (https://dreyvault.abhishekm.work/install) and set the server URL to $ORIGIN." say "" say "Update later: cd \"$DV_DIR\" && docker compose pull && docker compose up -d" say "Settings live in $DV_DIR/.env; re-running this installer keeps them."