#!/usr/bin/env bash # # OtoDock — one-command install for the Docker quick start. # # mkdir otodock && cd otodock # curl -fsSLO https://raw.githubusercontent.com/OtoDock/oto-dock/main/scripts/install.sh # bash install.sh # # What it does (each step announces itself before it runs): # 1. Checks that Docker and the Compose plugin are installed and reachable. # 2. Installs into the directory it is run from (give it a folder of its # own) — and refuses to touch a directory that already holds an install. # $OTODOCK_DIR is still honored as an override for older one-liners. # On Linux it then checks the free disk where Docker keeps its images # (OTODOCK_MIN_FREE_GB, 13 by default, moves the floor; 0 skips it). # 3. Writes a minimal .env with a generated PostgreSQL password (kept as-is # if one already exists; the server appends its own generated secrets to # the same file on first boot). # 4. Ubuntu 24.04+ only: installs the scoped `otodock_userns` AppArmor # profile the agent sandbox needs on such hosts. May ask for your sudo # password; the system-wide hardening stays enabled. Other hosts skip # it entirely. # 5. Linux only: applies the recommended vm.swappiness=10 host tuning via # /etc/sysctl.d/ (sudo again; declining it is safe — the install # continues either way). # 6. Downloads the release-pinned docker-compose.yml, starts the stack, and # prints the dashboard URL. # # Fresh installs only: it never upgrades or overwrites an existing install # (upgrade one with scripts/upgrade.sh, see # https://docs.otodock.io/administration/upgrading). Safe to re-run # if a step stopped it: everything that already exists is kept. All files land # in the current directory; nothing else on the host is touched (except the # optional AppArmor profile in step 4 and the optional sysctl drop-in in # step 5, each documented in its own short script). set -euo pipefail # Where files are fetched from. OTODOCK_REF selects a branch or tag — the # default `main` pins the release current at the time you run this, exactly # like downloading docker-compose.yml by hand. _ref="${OTODOCK_REF:-main}" _raw="https://raw.githubusercontent.com/OtoDock/oto-dock/${_ref}" say() { echo "install.sh: $*"; } fail() { echo "install.sh: $*" >&2; exit 1; } # An https public URL means a reverse proxy in front, and with TRUSTED_PROXY # empty every visitor shares its address: one person's failed sign-ins lock # everyone out. The server says so at every boot and shows admins a banner; # the installer says it first. The shell environment wins over .env, as for # docker compose itself. env_value() { # env_value : the shell's value, else the last .env line's, unquoted local v="${!1:-}" [ -n "$v" ] || v="$(grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d= -f2- || true)" v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}" printf '%s' "$v" } trusted_proxy_hint() { case "$(env_value DASHBOARD_PUBLIC_URL)" in [Hh][Tt][Tt][Pp][Ss]://*) ;; *) return 0 ;; esac [ -z "$(env_value TRUSTED_PROXY)" ] || return 0 say "DASHBOARD_PUBLIC_URL is https but TRUSTED_PROXY is empty: behind a reverse proxy every visitor shares its address. In .env add the address it connects from: for one on this host, PROXY_BIND_IP=127.0.0.1 and TRUSTED_PROXY set to the otodock network's gateway (10.200.0.1 by default: docker network inspect otodock -f '{{(index .IPAM.Config 0).Gateway}}'); for one on another machine, its IP. Then run: docker compose up -d" } # Whether a version (Docker Engine API, Docker Compose) is at least .. at_least() { # at_least awk -v v="${1#v}" -v M="$2" -v m="$3" 'BEGIN { n = split(v, a, "."); if (n < 2) exit 1 exit !((a[1] + 0 > M) || (a[1] + 0 == M && a[2] + 0 >= m)) }' } # The document editor's key is mounted as one file of a volume (a subpath): # Docker Engine 26 (API 1.45) and Docker Compose 2.26 or later. An older engine, # or a Compose that parses the field but does not pass it on (2.25), mounts the # whole volume and the editor runs without its key, every document refused. docker_floor() { local api cv api="$(docker version --format '{{.Server.APIVersion}}' 2>/dev/null || true)" if ! at_least "$api" 1 45; then fail "Docker Engine 26 or later is needed (this one speaks API ${api:-unknown}): the document editor's key is mounted as one file of a volume, which older engines cannot do. Update Docker (https://docs.docker.com/engine/install/), then re-run." fi cv="$(docker compose version --short 2>/dev/null || true)" if ! at_least "$cv" 2 26; then fail "Docker Compose 2.26 or later is needed (this one is ${cv:-unknown}): an older Compose mounts the document editor's key wrong. Update Docker, then re-run." fi } # .env's COLLABORA_SSL_TERMINATION set from the scheme of DASHBOARD_PUBLIC_URL # (true for https; false, the compose file's default, writes no new line): # the editor's image has no shell to derive it at start. Idempotent. collabora_scheme_line() { local want have want=false case "$(env_value DASHBOARD_PUBLIC_URL)" in [Hh][Tt][Tt][Pp][Ss]://*) want=true ;; esac have="$(awk '{ line = $0; sub(/^[ \t]*/, "", line); sub(/^export[ \t]+/, "", line) if (match(line, /^COLLABORA_SSL_TERMINATION[ \t]*=/)) { v = substr(line, RLENGTH + 1); f = 1 } } END { if (f) { gsub(/["\047 \t]/, "", v); print v } }' .env)" [ "${have:-false}" != "$want" ] || return 0 say "setting COLLABORA_SSL_TERMINATION=$want in .env (the scheme of DASHBOARD_PUBLIC_URL)" awk -v val="$want" ' { line = $0; sub(/^[ \t]*/, "", line); sub(/^export[ \t]+/, "", line) if (match(line, /^COLLABORA_SSL_TERMINATION[ \t]*=/)) { print "COLLABORA_SSL_TERMINATION=" val; f = 1; next } } { print } END { if (!f) print "COLLABORA_SSL_TERMINATION=" val }' .env | (umask 077; cat > .env.scheme) # In place: the file keeps its owner and mode. cat .env.scheme > .env rm -f .env.scheme } # Fetch a file atomically: a failed download must not leave a partial file # behind (a half-written docker-compose.yml would look like an existing # install on the next run). fetch() { # fetch curl -fsSL "$1" -o "$2.tmp" || fail "could not download $1 — check your network and retry." mv "$2.tmp" "$2" } # Free disk where Docker keeps its images, before a pull of about 11 GB: a # first start adds about 1.5 GB more, and the server refuses uploads while # less than the larger of 5 GB and 5 % of the disk is free. Linux with a local # daemon only (a remote daemon, or Docker Desktop's VM, keeps the images # elsewhere); an answer that cannot be read skips the check. disk_preflight() { local floor_gb="${OTODOCK_MIN_FREE_GB:-13}" endpoint root line total_k free_k local free_gb upload_k upload_gb [ "$(uname -s 2>/dev/null || true)" = Linux ] || return 0 if ! [[ "$floor_gb" =~ ^[0-9]+$ ]]; then say "OTODOCK_MIN_FREE_GB='$floor_gb' is not a whole number of GB: using 13." floor_gb=13 fi [ "$floor_gb" -gt 0 ] || return 0 endpoint="$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)" case "$endpoint" in unix://*) ;; *) return 0 ;; esac case "$(docker info -f '{{.OperatingSystem}}' 2>/dev/null || true)" in *"Docker Desktop"*) return 0 ;; esac root="$(docker info -f '{{.DockerRootDir}}' 2>/dev/null || true)" [ -n "$root" ] || return 0 line="$(df -Pk "$root" 2>/dev/null | awk 'NR == 2 { print $2, $4 }')" || return 0 read -r total_k free_k <<< "$line" [[ "${total_k:-}" =~ ^[0-9]+$ && "${free_k:-}" =~ ^[0-9]+$ ]] || return 0 free_gb=$((free_k / 1024 / 1024)) if [ "$free_k" -lt $((floor_gb * 1024 * 1024)) ]; then if [ -n "$(docker image ls -q ghcr.io/otodock/otodock-proxy 2>/dev/null || true)" ]; then say "only ${free_gb} GB free where Docker keeps its images ($root); the release images are already on this host, so going on." return 0 fi fail "only ${free_gb} GB free where Docker keeps its images ($root). The install needs about ${floor_gb} GB: about 11 GB of release images and 1.5 GB for the first start. Free some space (docker system prune removes unused images and build cache), or run with OTODOCK_MIN_FREE_GB=0 to install anyway." fi upload_k=$((total_k / 20)) [ "$upload_k" -ge $((5 * 1024 * 1024)) ] || upload_k=$((5 * 1024 * 1024)) if [ "$free_k" -lt $((13 * 1024 * 1024 + upload_k)) ]; then upload_gb=$((upload_k / 1024 / 1024)) say "${free_gb} GB free where Docker keeps its images ($root): after the install, less than ${upload_gb} GB stays free, and the server refuses uploads until it is (it keeps the larger of 5 GB and 5 % of the disk free). Going on." fi } # --- 1. Preflight ------------------------------------------------------------ # Run as a regular user: the stack itself needs no root, and the server's own # files should not end up root-owned. Sudo is used only for the optional # AppArmor and host-tuning steps below. if [ "$(id -u)" -eq 0 ]; then fail "please run as a regular user, not root — sudo is used only where needed. (On a fresh server: adduser && usermod -aG docker,sudo , then re-run as them.)" fi command -v curl >/dev/null 2>&1 || fail "curl is required — install it first (apt-get install curl)." if ! command -v docker >/dev/null 2>&1; then fail "Docker is not installed. Install Docker Engine first: https://docs.docker.com/engine/install/ (Debian/Ubuntu short version: curl -fsSL https://get.docker.com | sh sudo usermod -aG docker \$USER # then log out and back in ) — then re-run this script." fi if ! docker compose version >/dev/null 2>&1; then fail "the Docker Compose plugin is missing (\`docker compose version\` failed). Install it: https://docs.docker.com/compose/install/linux/ — then re-run this script." fi if ! docker info >/dev/null 2>&1; then fail "cannot talk to the Docker daemon. Is it running, and is your user in the 'docker' group? (sudo usermod -aG docker \$USER — then log out and back in.)" fi docker_floor # Architecture — the release images publish linux/amd64 + linux/arm64 manifests. # Anything else has no matching manifest, and the failure would otherwise surface # much later as an opaque "no match for platform" pull error. _arch="$(uname -m)" case "$_arch" in x86_64|amd64|aarch64|arm64) ;; *) fail "unsupported CPU architecture '$_arch'. The release images are built for x86-64 and arm64 only. To run on this host, build from source instead: https://github.com/OtoDock/oto-dock/blob/main/CONTRIBUTING.md" ;; esac # --- 2. Install directory ---------------------------------------------------- # The install lands right here, in the directory the script is run from — # give it a folder of its own first (mkdir otodock && cd otodock). OTODOCK_DIR # is honored as an override so older one-liners keep installing where they # always did. if [ -n "${OTODOCK_DIR:-}" ]; then mkdir -p "$OTODOCK_DIR" cd "$OTODOCK_DIR" fi if [ -n "${HOME:-}" ] && [ "$(pwd -P)" = "$(cd "$HOME" && pwd -P)" ]; then fail "this is your home directory — give the install a folder of its own: mkdir otodock && cd otodock then re-run this script from there." fi if [ -f docker-compose.yml ]; then fail "this directory already contains a docker-compose.yml — this script performs fresh installs only and never touches an existing one. To upgrade it: run upgrade.sh in that folder: curl -fsSLO $_raw/scripts/upgrade.sh bash upgrade.sh (https://docs.otodock.io/administration/upgrading) To install fresh: create a new, empty folder (mkdir otodock && cd otodock) and re-run this script from there." fi disk_preflight say "installing into $(pwd)" # --- 3. Configuration (.env) ------------------------------------------------- # One file is the whole configuration: Compose reads it automatically, and on # first boot the server generates its remaining secrets (API key, signing # keys, …) and appends them here — so back this file up with your data. # A random hex secret (openssl if present, else /dev/urandom). rand_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 24 else od -vAn -N24 -tx1 /dev/urandom | tr -d ' \n' fi } # Compose loads docker-compose.override.yml by itself only while COMPOSE_FILE # is unset, so an override placed here goes on that line, after the base file # and the phone overlay (the order upgrade.sh keeps too). _compose_files="docker-compose.yml:docker-compose.phone.yml" if [ -f docker-compose.override.yml ]; then _compose_files="${_compose_files}:docker-compose.override.yml" fi if [ -f .env ]; then say "keeping the existing .env" if [ -f docker-compose.override.yml ] \ && ! grep -qE '^[[:space:]]*COMPOSE_FILE=.*docker-compose\.override\.yml' .env; then say "docker-compose.override.yml is here but the kept .env does not list it on COMPOSE_FILE, so compose will not load it: add it at the end of that line." fi # A set key gives the DB superuser a network password on every start (the # opt-in described in the template below); say so rather than keep it quietly. if grep -qE '^OTODOCK_DB_ADMIN_PASSWORD=.' .env; then say "OTODOCK_DB_ADMIN_PASSWORD is set: the DB superuser gets that network password on every start (opt-in). Delete the line to drop it again." fi else say "writing .env with a generated database password" _pw="$(rand_hex)" # Created private (umask 077 in a subshell): the file holds the database # password now and every generated secret after first boot. ( umask 077; cat > .env ) </dev/null 2>&1 || ! "${_sudo[@]}" bash setup-apparmor-userns.sh; then # Starting anyway would boot-loop the server against the kernel # restriction — a clear stop beats a broken start. fail "could not install the profile (sudo unavailable or declined). Run once, as an administrator: sudo bash $(pwd)/setup-apparmor-userns.sh then re-run this installer — it picks up where it left off. Do NOT disable the sysctl system-wide — the scoped profile is the supported path." fi else say "the scoped AppArmor profile 'otodock_userns' is already installed — reusing it" fi say "selecting the profile in .env (OTODOCK_APPARMOR_PROFILE=otodock_userns)" echo "OTODOCK_APPARMOR_PROFILE=otodock_userns" >> .env fi # --- 5. Host tuning: keep the platform out of swap --------------------------- # Linux's default vm.swappiness=60 lets a memory burst from a sandboxed tool # container push the OtoDock server's own processes into swap — and the next # request that touches those pages stalls the dashboard while they page back # in. scripts/setup-host-tuning.sh writes /etc/sysctl.d/90-otodock.conf with # vm.swappiness=10 (standard sysctl precedence — easy to override or remove). # Best-effort on purpose: this is a tuning, not a requirement, so an install # never fails over it. Hosts without the knob (macOS/Windows) or already at # <= 10 skip it silently inside the script. if [ "$(cat /proc/sys/vm/swappiness 2>/dev/null || echo 0)" -gt 10 ]; then say "tuning the host to keep OtoDock out of swap (vm.swappiness=10 via /etc/sysctl.d/90-otodock.conf — one time, needs sudo; skipping is safe):" # Best-effort throughout — a tuning step must never abort the install, # so even its download failing only skips it (unlike the fatal fetch()). if curl -fsSL "$_raw/scripts/setup-host-tuning.sh" -o setup-host-tuning.sh.tmp \ && mv setup-host-tuning.sh.tmp setup-host-tuning.sh; then _sudo=(sudo) if [ ! -t 0 ]; then _sudo=(sudo -n); fi if ! command -v sudo >/dev/null 2>&1 || ! "${_sudo[@]}" bash setup-host-tuning.sh; then say "skipped (sudo unavailable or declined) — OtoDock runs fine without it. For smoother behaviour under memory pressure, run once when convenient: sudo bash $(pwd)/setup-host-tuning.sh" fi else rm -f setup-host-tuning.sh.tmp say "skipped (could not download the tuning script) — OtoDock runs fine without it; see scripts/setup-host-tuning.sh in the repo to apply it later." fi fi # --- 6. Fetch the compose files and start ------------------------------------ # The compose files pin the OtoDock release they shipped with, so the install # is reproducible; scripts/upgrade.sh moves it to a newer release later, new # compose files included. docker-compose.yml is fetched LAST on purpose: its # presence is what marks this directory as an install, so the overlay must # already be in place by then (a failed overlay fetch must not leave a # half-marked install). say "downloading the phone service overlay (docker-compose.phone.yml)" fetch "$_raw/docker-compose.phone.yml" docker-compose.phone.yml say "downloading docker-compose.yml (release-pinned)" fetch "$_raw/docker-compose.yml" docker-compose.yml collabora_scheme_line say "starting OtoDock — the first run pulls the release images, which can take a few minutes on a fresh host" docker compose up -d || fail "docker compose up failed (the message above). The compose files and .env stay in $(pwd), and install.sh refuses a folder that holds them: fix the cause, then run docker compose up -d in that folder." # PROXY_PORT moves the published port; the shell environment wins over .env, # matching how docker compose itself resolves it. _port="${PROXY_PORT:-$(grep -E '^PROXY_PORT=' .env | tail -n1 | cut -d= -f2- || true)}" _port="${_port:-8400}" echo say "done. OtoDock is starting at: http://localhost:${_port} (from this machine) http://:${_port} (from your network) A fresh install greets you with the setup wizard — create your admin account there, then connect your AI subscription or API key. First run: https://docs.otodock.io/getting-started/first-run Logs: docker compose logs -f otodock-proxy (in $(pwd))" trusted_proxy_hint