#!/usr/bin/env bash # Keel installer: sets up Keel with the published images on Linux, macOS # (Docker Desktop, OrbStack or Colima), Windows (WSL 2) and NAS systems with # Docker (ZimaOS, CasaOS, Synology, Unraid, TrueNAS SCALE, Raspberry Pi OS, ...). # On Windows without WSL, use install.ps1 instead. Works in bash 3.2 (macOS). # # curl -fsSL https://www.keeldb.name/install.sh | sudo bash # curl -fsSL https://www.keeldb.name/install.sh | sudo bash -s -- --lan --version 0.2.0 # # Or download it, check it and then run it (see the install guide): # curl -fsSLO https://www.keeldb.name/install.sh && curl -fsSLO https://www.keeldb.name/install.sh.sha256 # sha256sum -c install.sh.sha256 && sudo bash install.sh # # It checks every file it downloads against SHA256SUMS, published next to them. # # Run it again to update an existing install: it backs up every database # first, and --rollback goes back to the version before. Options, as flags or # as environment variables: # --dir DIR KEEL_DIR where to install (default: depends on the # system, e.g. /opt/keel, ~/keel, /volume1/docker/keel) # --version V KEEL_VERSION version to install or update to, e.g. 0.2.0 # (default: the newest release) # --url URL KEEL_PUBLIC_URL how apps reach the API (default: http://:8000) # --host HOST KEEL_HOST just the address part of that, e.g. nas.local # --lan KEEL_LAN=yes open Studio to your network # --no-lan KEEL_LAN=no keep Studio on this machine only # --lan-db KEEL_DB_LAN=yes open Postgres to your network too # --yes KEEL_YES=1 don't ask questions; use the defaults # --rollback go back to the version before the last update # --restore with --rollback: also restore the databases # from the backup taken before that update # # Studio and Postgres listen on this machine only unless you choose otherwise. # On a NAS, Studio is opened to your network by default. On a cloud server # (detected from the provider's metadata service or the machine's firmware), # both always stay on this machine unless you pass --lan. set -euo pipefail SITE="https://www.keeldb.name" IMAGES="ghcr.io/niklan1110" # Where the compose file and the keel command come from: the website, or for # the installer attached to a GitHub release, that release (whose version is # then the default). FILES="https://www.keeldb.name" RELEASE="" say() { printf '%s\n' "$*"; } warn() { printf 'keel: %s\n' "$*" >&2; } die() { printf 'keel: %s\n' "$*" >&2; exit 1; } usage() { cat <<'USAGE' Keel installer. Options (or the environment variables in brackets): --dir DIR where to install [KEEL_DIR] --version V version, e.g. 0.2.0 (default: the newest release) [KEEL_VERSION] --url URL how apps reach the API [KEEL_PUBLIC_URL] --host HOST just the address part of that [KEEL_HOST] --lan open Studio to your network [KEEL_LAN=yes] --no-lan keep Studio on this machine only [KEEL_LAN=no] --lan-db open Postgres to your network too [KEEL_DB_LAN=yes] --yes don't ask questions [KEEL_YES=1] --rollback go back to the version before the last update --restore with --rollback: also restore the databases backed up before it USAGE } # --- Options ------------------------------------------------------------------ dir=${KEEL_DIR:-} version=${KEEL_VERSION:-} url=${KEEL_PUBLIC_URL:-} host=${KEEL_HOST:-} lan=${KEEL_LAN:-} db_lan=${KEEL_DB_LAN:-} assume_yes=${KEEL_YES:-} rollback="" restore="" while [ $# -gt 0 ]; do case "$1" in --dir) dir=${2:-}; shift 2 ;; --version) version=${2:-}; shift 2 ;; --url) url=${2:-}; shift 2 ;; --host) host=${2:-}; shift 2 ;; --lan) lan=yes; shift ;; --no-lan) lan=no; shift ;; --lan-db) db_lan=yes; shift ;; --yes|-y) assume_yes=1; shift ;; --rollback) rollback=1; shift ;; --restore) restore=1; shift ;; -h|--help) usage; exit 0 ;; *) die "unknown option $1 (see the top of this script for the options)" ;; esac done case "$lan" in ''|yes|no) ;; 1|true|on) lan=yes ;; 0|false|off) lan=no ;; *) die "KEEL_LAN must be yes or no" ;; esac case "$db_lan" in ''|no|0|false|off) db_lan="" ;; yes|1|true|on) db_lan=yes ;; *) die "KEEL_DB_LAN must be yes or no" ;; esac if [ -n "$version" ]; then version=${version#v} [[ "$version" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die "--version: use a tag such as 0.2.0 or latest" fi if [ -n "$url" ] && ! [[ "$url" =~ ^https?://[^[:space:]]+$ ]]; then die "--url must look like http://192.168.1.20:8000 or https://api.example.com" fi # Questions go to the terminal even when this script arrives through a pipe. tty_ok="" if [ -z "$assume_yes" ] && [ -t 1 ] && { : /dev/null; then tty_ok=1; fi ask() { # ask "question" default(y|n) -> 0 for yes local answer="" if [ -z "$tty_ok" ]; then [ "$2" = y ]; return; fi printf '%s [%s] ' "$1" "$([ "$2" = y ] && echo Y/n || echo y/N)" >/dev/tty read -r answer /dev/null || command -v midclt >/dev/null 2>&1; then platform=truenas elif [ -n "${WSL_DISTRO_NAME:-}" ] || grep -qi microsoft /proc/version 2>/dev/null; then platform=wsl fi is_nas() { case "$platform" in zimaos|synology|unraid|truenas) return 0 ;; *) return 1 ;; esac; } command -v docker >/dev/null 2>&1 || case "$platform" in macos) die "Docker is required: install Docker Desktop or OrbStack, start it, then run this again (without sudo)." ;; wsl) die "Docker is required: install Docker Desktop for Windows and turn on WSL integration for this distribution (Settings > Resources > WSL integration)." ;; *) die "Docker is required: https://docs.docker.com/engine/install/" ;; esac if ! docker compose version >/dev/null 2>&1; then case "$platform" in unraid) die "Docker Compose is required: install the Compose Manager plugin (Apps > Compose Manager), then run this again." ;; # Docker Desktop and OrbStack include it; with Colima it comes from Homebrew. macos) die "the Docker Compose plugin is required (docker compose version). With Colima: brew install docker-compose, then follow its note on cliPluginsExtraDirs." ;; *) die "the Docker Compose plugin is required (docker compose version)" ;; esac fi if ! docker info >/dev/null 2>&1; then case "$platform" in macos) die "can't talk to Docker. Start Docker Desktop or OrbStack and run this as your own user (no sudo)." ;; *) die "can't talk to Docker. Run this as root (sudo bash) or as a user in the docker group." ;; esac fi command -v curl >/dev/null 2>&1 || die "curl is required" # Docker Desktop, rootless Docker and Podman hand containers every outside # connection from the Docker network's gateway, so Postgres can't tell this # machine from the rest of the network (KEEL_DB_GATEWAY=remote handles that). docker_hides_clients() { case "$platform" in macos|wsl) return 0 ;; esac docker info -f '{{.SecurityOptions}} {{.OperatingSystem}}' 2>/dev/null | grep -qi 'rootless\|docker desktop' && return 0 docker --version 2>/dev/null | grep -qi podman && return 0 return 1 } # The home folder of the person running this (also under sudo). user_home() { local h="" if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then h=$(eval "echo ~$SUDO_USER" 2>/dev/null || true) fi echo "${h:-$HOME}" } # TrueNAS: data belongs on a pool under /mnt, never on the boot pool. truenas_dir() { local pools=() p for p in /mnt/*/; do p=${p%/} case "$p" in /mnt/.*|/mnt/'*') continue ;; esac [ -d "$p" ] && pools+=("$p") done if [ ${#pools[@]} -eq 1 ]; then echo "${pools[0]}/apps/keel"; return; fi if [ -n "$tty_ok" ] && [ ${#pools[@]} -gt 1 ]; then say "Pools found: ${pools[*]}" >/dev/tty printf 'Install Keel on which pool? [%s] ' "${pools[0]}" >/dev/tty read -r p /apps/keel" } if [ -z "$dir" ]; then case "$platform" in zimaos) dir=/DATA/AppData/keel ;; synology) vol=/volume1 [ -d "$vol" ] || vol=$(find / -maxdepth 1 -name 'volume[0-9]*' -type d 2>/dev/null | sort | head -n 1) [ -n "$vol" ] || die "Synology: no /volume1 found; pass --dir /volumeN/docker/keel" dir=$vol/docker/keel ;; # / is a RAM disk on Unraid: anything outside /mnt is gone after a reboot. unraid) dir=/mnt/user/appdata/keel ;; truenas) dir=$(truenas_dir) ;; # Docker Desktop only shares /Users (and a few others) with containers. macos) dir=$(user_home)/keel ;; *) if [ "$(id -u)" -eq 0 ]; then dir=/opt/keel; else dir=$HOME/keel; fi ;; esac fi case "$dir" in /*) ;; *) dir=$PWD/$dir ;; esac case "$platform:$dir" in unraid:/mnt/*) ;; unraid:*) die "on Unraid, install under /mnt (e.g. /mnt/user/appdata/keel): everything else is lost on reboot" ;; macos:/opt/*|macos:/usr/*) die "Docker Desktop can't share $dir with containers; use a folder in your home (e.g. ~/keel)" ;; wsl:/mnt/[a-zA-Z]/*) warn "$dir is on a Windows drive. Postgres there is slow and can lose data; a folder inside WSL (e.g. ~/keel or /opt/keel) is much better." ;; esac # SHA-256 of a file: sha256sum on Linux and most NAS systems, shasum on macOS. sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" else openssl dgst -sha256 -r "$1" fi | awk '{ print $1 }' } # Downloads a published file, but only keeps it if it matches its checksum in # SHA256SUMS (published next to it). sums="" fetch() { local want got if [ -z "$sums" ]; then sums=$(curl -fsSL "$FILES/SHA256SUMS") || die "could not download $FILES/SHA256SUMS" fi want=$(printf '%s\n' "$sums" | awk -v f="$1" '$2 == f || $2 == "*" f { print $1; exit }') [ -n "$want" ] || die "$1 is not listed in $FILES/SHA256SUMS" curl -fsSL "$FILES/$1" -o "$2.download" || { rm -f "$2.download"; die "could not download $FILES/$1"; } got=$(sha256_of "$2.download" 2>/dev/null || true) if [ "$got" != "$want" ]; then rm -f "$2.download" die "$1 doesn't match its published checksum (expected $want, got ${got:-nothing}); nothing was changed" fi mv -f "$2.download" "$2" } # Value of a setting in .env (last one wins, like docker compose). env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n 1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' || true; } # Sets a setting in .env, replacing an existing line (no sed -i: BSD and GNU differ). env_set() { local tmp tmp=$(mktemp .env.XXXXXX) { grep -vE "^$1=" .env || true; printf '%s=%s\n' "$1" "$2"; } > "$tmp" chmod 600 "$tmp" mv -f "$tmp" .env } # Keeps the last few copies of a file as NAME.bak.