Skip to content

Instantly share code, notes, and snippets.

@osteinhauer
Created July 29, 2026 09:47
Show Gist options
  • Select an option

  • Save osteinhauer/2ee5e5f7610e6ed276d7bd9629dacc71 to your computer and use it in GitHub Desktop.

Select an option

Save osteinhauer/2ee5e5f7610e6ed276d7bd9629dacc71 to your computer and use it in GitHub Desktop.
paperless-ngx auf macOS installieren - nutzt vorhandenes Docker, Colima nur als Rueckfallebene. PostgreSQL 18, Time-Machine-taugliche Bind-Mounts. GPL-3.0-or-later

paperless-ngx auf macOS installieren

Ein Installationsskript, das paperless-ngx mit PostgreSQL 18 und Valkey auf einem Mac einrichtet — mit möglichst wenig zusätzlicher Software.

Der Leitgedanke: nichts installieren, was schon da ist. Läuft auf dem Rechner bereits Docker, benutzt das Skript es und fasst weder Homebrew noch sonst etwas an. Erst wenn gar keine Container-Runtime vorhanden ist, bietet es Colima als Rückfallebene an — und auch das erst nach Rückfrage.

Wahl der Container-Runtime

1. Ein Docker-Daemon ist erreichbar          → benutzen, nichts installieren
2. Docker Desktop installiert, aber gestoppt → fragen: „jetzt starten?"
3. Rancher Desktop installiert, aber gestoppt→ dieselbe Frage
4. Colima-Instanz „paperless" existiert      → benutzen
5. Nichts davon                              → fragen: „Colima einrichten?"

Vor der Colima-Frage steht ausdrücklich, was sie bedeutet: Homebrew wird installiert oder aktualisiert, die Formeln colima, docker und docker-compose kommen dazu, und es entsteht eine Linux-VM. Wer ablehnt, bekommt einen Hinweis auf Docker Desktop und das Skript beendet sich, ohne etwas verändert zu haben.

Wird Colima verwendet, läuft es als eigene Instanz (--profile paperless). Ein bereits vorhandenes Colima-Standardprofil bleibt unangetastet, ebenso Docker Desktop und Rancher Desktop. Die Verbindung geht über den Socket dieser Instanz statt über den aktiven Docker-Kontext — sonst landen die Container auf Rechnern mit mehreren Runtimes in der falschen VM.

Aufruf

chmod +x install-paperless-macos.sh
./install-paperless-macos.sh

Danach läuft paperless-ngx unter http://127.0.0.1:8000. Die Zugangsdaten für den ersten Login gibt das Skript am Ende aus; sie stehen auch in ~/paperless-ngx/.env.

Option Bedeutung
--dir PFAD Installationsverzeichnis (Default ~/paperless-ngx)
--port PORT Port der Weboberfläche (Default 8000)
--bind ADRESSE Bind-Adresse, 0.0.0.0 für Zugriff aus dem LAN
--languages SPRACHEN OCR-Sprachen in Tesseract-Syntax (Default deu+eng)
--timezone ZONE Zeitzone (Default Europe/Berlin)
--runtime WAHL auto (Default), docker oder colima erzwingen
--cpu / --memory / --disk Größe der Colima-VM, sonst aus der Hardware abgeleitet
--yes Rückfragen automatisch bejahen
--dry-run Nichts installieren, nur die Konfigurationsdateien erzeugen
--no-start Alles einrichten, Container aber nicht starten
--uninstall Container entfernen (Daten bleiben erhalten)

Ein erneuter Aufruf aktualisiert eine bestehende Installation: neue Images, geänderte Konfiguration, Datenbank-Migrationen — ohne Datenverlust. Secrets und Anwendungseinstellungen werden dabei übernommen, nicht neu erzeugt.

Ohne Terminal (Cron, CI, Pipe) gilt jede Rückfrage als nein. So installiert ein automatisierter Aufruf nichts ungefragt.

Größe der VM

Wird Colima verwendet, leitet das Skript die VM-Größe aus dem Rechner ab, statt feste Werte zu setzen:

Wert Ableitung Grenzen
CPUs halbe Kernzahl 2 bis 6
RAM ein Drittel des Systemspeichers 3 bis 8 GB
Disk halber freier Platz 30 bis 100 GB

Colima läuft neben macOS, nicht statt macOS — deshalb die Zurückhaltung. Die Disk wächst nur nach Bedarf, der Wert ist eine Obergrenze.

Wo die Daten liegen

Alle Nutzdaten liegen als Bind-Mount auf der Platte, nicht in Docker-Volumes:

~/paperless-ngx/
├── consume/        hier Dokumente hineinlegen
├── media/          Originale und Thumbnails
├── data/           Suchindex, Klassifikationsmodell
├── pgdata/         PostgreSQL-Datenverzeichnis
├── export/         Ziel von "paperlessctl backup"
├── paperless.env   Anwendungseinstellungen
├── .env            Passwörter und Schlüssel (Modus 600)
└── docker-compose.yml

Damit erfasst Time Machine die Dokumente im normalen Lauf. Ein Volume läge im Disk-Image der VM und wäre für Time Machine nur ein ständig veränderter Klumpen.

Zur Sicherung: Ein Time-Machine-Abzug von pgdata/ entsteht, während die Datenbank läuft, und ist dadurch nicht garantiert wiederherstellbar. Für eine verlässliche Sicherung zusätzlich regelmäßig paperlessctl backup laufen lassen — das schreibt Dokumente und Metadaten konsistent nach export/.

Verwaltung

Das Skript legt ~/paperless-ngx/bin/paperlessctl an:

paperlessctl status           # Runtime und Container
paperlessctl start | stop | restart
paperlessctl logs webserver   # Logs folgen
paperlessctl update           # neue Images holen und erneuern
paperlessctl backup           # konsistenter Export nach export/
paperlessctl manage ...       # Django-Kommando, z.B. createsuperuser
paperlessctl psql             # psql-Sitzung in der Datenbank
paperlessctl repair-password  # Datenbank-Passwort mit .env abgleichen

repair-password löst einen Fall, der durch die Bind-Mounts entstehen kann: PostgreSQL wertet POSTGRES_PASSWORD nur bei der Erstinitialisierung aus. Bleibt pgdata/ bestehen, während .env neu erzeugt wird — etwa weil nur ein Teil der Installation gelöscht wurde — trägt der Cluster weiter das alte Passwort und paperless scheitert mit password authentication failed. Das Skript gleicht das Passwort inzwischen bei jedem Lauf ab; der Befehl ist für den Fall gedacht, dass es einen zwischen zwei Läufen erwischt.

macOS-Besonderheiten

Kein inotify über Mount-Grenzen. Der consume/-Ordner liegt auf dem Mac und wird in die Linux-VM gereicht. Dateisystem-Ereignisse kommen dabei nicht zuverlässig an, deshalb arbeitet paperless dort mit Polling (PAPERLESS_CONSUMER_POLLING=10). Neue Dokumente werden also nach etwa zehn Sekunden erkannt, nicht sofort.

Sparsame OCR-Sprachen. Das paperless-Image bringt Englisch, Deutsch, Italienisch, Spanisch und Französisch mit. Nur darüber hinausgehende Sprachen werden beim Containerstart nachinstalliert — --languages deu+eng löst also keinen Download aus.

Voraussetzungen

macOS 13 oder neuer, Apple Silicon oder Intel. Alles Weitere richtet das Skript ein oder erfragt es.

Wird Colima gewählt, braucht es echte Virtualisierung. In einer macOS-VM (Parallels, UTM, VMware) ist das nicht gegeben: Apples Virtualization.framework reicht keinen Hypervisor an macOS-Gäste durch, und colima start scheitert mit Virtualization is not available on this hardware. Auf echter Hardware tritt das nicht auf.

Stand

Images und Volume-Pfade folgen der offiziellen docker-compose.postgres.yml von paperless-ngx (v3.0.x): Valkey 9, PostgreSQL 18, ghcr.io/paperless-ngx/paperless-ngx. PostgreSQL ist bewusst auf die Major-Version gepinnt — ein Sprung auf 19 erfordert Dump und Restore und soll nicht unbemerkt bei einem Update passieren.

Lizenz

Copyright (C) 2026 Oliver Steinhauer

Dieses Programm ist freie Software: Sie können es unter den Bedingungen der GNU General Public License, Version 3 oder (nach Ihrer Wahl) jeder späteren Version, weitergeben und/oder verändern.

Die Veröffentlichung erfolgt in der Hoffnung, dass es nützlich sein wird, aber ohne jede Gewährleistung — sogar ohne die implizite Gewährleistung der Marktreife oder der Verwendbarkeit für einen bestimmten Zweck. Details in der GNU General Public License: https://www.gnu.org/licenses/gpl-3.0.html

SPDX-License-Identifier: GPL-3.0-or-later

#!/bin/bash
#
# install-paperless-macos.sh
#
# Installiert paperless-ngx auf macOS mit PostgreSQL 18 und Valkey.
# Laeuft auf Apple Silicon und Intel, ab macOS 13.
#
# Container-Runtime: eine vorhandene Docker-Installation wird bevorzugt.
# Laeuft Docker Desktop oder Rancher Desktop nur nicht, wird gefragt, ob es
# gestartet werden soll. Ist gar kein Docker vorhanden, kann Colima als
# Rueckfallebene eingerichtet werden - ebenfalls erst nach Rueckfrage.
#
# Installiert wird nur, was fehlt:
# - bei vorhandenem Docker: nichts
# - sonst Homebrew sowie colima, docker (CLI) und docker-compose
#
# Images und Volume-Pfade folgen der offiziellen docker-compose.postgres.yml
# von paperless-ngx (Stand: v3.0.x).
#
# Das Skript ist idempotent: ein erneuter Aufruf aktualisiert die Installation
# (neue Images, geaenderte Konfiguration) ohne Datenverlust.
#
# Aufruf:
# ./install-paperless-macos.sh [OPTIONEN]
#
# Optionen:
# --dir PFAD Installationsverzeichnis (Default: ~/paperless-ngx)
# --version TAG Image-Tag von paperless-ngx (Default: latest)
# --port PORT Port der Weboberflaeche (Default: 8000)
# --bind ADRESSE Bind-Adresse (Default: 127.0.0.1, "0.0.0.0" fuer LAN-Zugriff)
# --languages SPRACHEN OCR-Sprachen in tesseract-Syntax (Default: deu+eng)
# --timezone ZONE Zeitzone (Default: Europe/Berlin)
# --admin-user NAME Administrator-Konto (Default: admin)
# --cpu N CPU-Kerne fuer die Colima-VM (Default: aus der Hardware)
# --memory GB Arbeitsspeicher der Colima-VM in GB (Default: aus der Hardware)
# --disk GB Plattengroesse der Colima-VM in GB (Default: aus dem freien Platz)
# --runtime WAHL Container-Runtime: auto (Default), docker, colima
# auto = vorhandenes Docker bevorzugen, Colima nur als
# Rueckfallebene und erst nach Rueckfrage
# --yes Rueckfragen automatisch mit ja beantworten
# --no-start Alles einrichten, aber VM und Container nicht starten
# --dry-run Nichts installieren, nichts starten - nur die
# Konfigurationsdateien erzeugen und berichten, was
# ein echter Lauf tun wuerde
# --uninstall Container und Volumes entfernen (nach Rueckfrage)
# -h, --help Diese Hilfe
#
# Lizenz: GPL-3.0-or-later, Copyright (C) 2026 Oliver Steinhauer
# Vollstaendiger Hinweis am Ende dieses Kopfes.
#
set -euo pipefail
# ---------------------------------------------------------------------------
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (C) 2026 Oliver Steinhauer
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
PAPERLESS_DIR="${HOME}/paperless-ngx"
IMAGE_TAG="latest"
WEB_PORT="8000"
BIND_ADDR="127.0.0.1"
OCR_LANGUAGES="deu+eng"
TIME_ZONE="Europe/Berlin"
ADMIN_USER="admin"
# Leer = wird weiter unten aus der Hardware des Rechners abgeleitet.
VM_CPU=""
VM_MEMORY=""
VM_DISK=""
START_STACK="yes"
DO_UNINSTALL="no"
DRY_RUN="no"
RUNTIME_CHOICE="auto"
ASSUME_YES="no"
# Bewusst auf Major-Versionen gepinnt: ein Sprung auf postgres:19 erfordert
# spaeter einen Dump/Restore, das darf nicht unbemerkt bei einem Update passieren.
POSTGRES_IMAGE="docker.io/library/postgres:18"
BROKER_IMAGE="docker.io/valkey/valkey:9-alpine"
PAPERLESS_IMAGE="ghcr.io/paperless-ngx/paperless-ngx"
COMPOSE_PROJECT="paperless"
# Eigene Colima-Instanz statt des Standardprofils. Auf Rechnern, auf denen
# schon Colima, Docker Desktop oder Rancher Desktop laeuft, wuerde das
# Standardprofil sonst dessen Konfiguration ueberschreiben.
COLIMA_PROFILE="paperless"
# ---------------------------------------------------------------------------
# Ausgabe-Helfer
# ---------------------------------------------------------------------------
if [ -t 1 ]; then
C_BLUE=$'\033[1;34m'; C_GREEN=$'\033[1;32m'; C_YELLOW=$'\033[1;33m'
C_RED=$'\033[1;31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
else
C_BLUE=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_DIM=""; C_OFF=""
fi
step() { printf '\n%s==>%s %s\n' "$C_BLUE" "$C_OFF" "$*"; }
info() { printf ' %s\n' "$*"; }
ok() { printf ' %s%s%s\n' "$C_GREEN" "$*" "$C_OFF"; }
warn() { printf ' %sWarnung:%s %s\n' "$C_YELLOW" "$C_OFF" "$*"; }
die() { printf '\n%sFehler:%s %s\n\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
# Gibt den Kommentarblock am Dateianfang aus - waechst automatisch mit.
usage() {
awk 'NR>2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "$0"
exit 0
}
# ---------------------------------------------------------------------------
# Argumente
# ---------------------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--dir) PAPERLESS_DIR="${2:?--dir braucht einen Pfad}"; shift 2 ;;
--version) IMAGE_TAG="${2:?--version braucht einen Tag}"; shift 2 ;;
--port) WEB_PORT="${2:?--port braucht eine Nummer}"; shift 2 ;;
--bind) BIND_ADDR="${2:?--bind braucht eine Adresse}"; shift 2 ;;
--languages) OCR_LANGUAGES="${2:?--languages braucht einen Wert}"; shift 2 ;;
--timezone) TIME_ZONE="${2:?--timezone braucht einen Wert}"; shift 2 ;;
--admin-user) ADMIN_USER="${2:?--admin-user braucht einen Namen}"; shift 2 ;;
--cpu) VM_CPU="${2:?--cpu braucht eine Zahl}"; shift 2 ;;
--memory) VM_MEMORY="${2:?--memory braucht eine Zahl}"; shift 2 ;;
--disk) VM_DISK="${2:?--disk braucht eine Zahl}"; shift 2 ;;
--runtime) RUNTIME_CHOICE="${2:?--runtime braucht auto|docker|colima}"; shift 2 ;;
--yes|-y) ASSUME_YES="yes"; shift ;;
--no-start) START_STACK="no"; shift ;;
--dry-run) DRY_RUN="yes"; shift ;;
--uninstall) DO_UNINSTALL="yes"; shift ;;
-h|--help) usage ;;
*) die "Unbekannte Option: $1 (--help fuer Hilfe)" ;;
esac
done
case "$RUNTIME_CHOICE" in
auto|docker|colima) ;;
*) die "--runtime kennt nur auto, docker oder colima (erhalten: ${RUNTIME_CHOICE})" ;;
esac
if [ "$DRY_RUN" = "yes" ]; then
[ "$DO_UNINSTALL" = "yes" ] && die "--dry-run und --uninstall schliessen sich aus."
# Im Trockenlauf wird grundsaetzlich nichts gestartet.
START_STACK="no"
fi
# Meldet einen uebersprungenen Schritt im Trockenlauf.
skip() { printf ' %suebersprungen:%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
# ---------------------------------------------------------------------------
# VM-Groesse aus der Hardware ableiten
# ---------------------------------------------------------------------------
#
# Colima laeuft neben macOS, nicht statt macOS - der Host braucht also Luft.
# Explizit uebergebene Werte (--cpu/--memory/--disk) bleiben unangetastet.
clamp() { # clamp <wert> <min> <max>
local value="$1" min="$2" max="$3"
[ "$value" -lt "$min" ] && value="$min"
[ "$value" -gt "$max" ] && value="$max"
printf '%s' "$value"
}
if [ -z "$VM_CPU" ]; then
HOST_CPUS="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)"
# Die Haelfte der Kerne, damit der Mac daneben bedienbar bleibt.
VM_CPU="$(clamp "$(( HOST_CPUS / 2 ))" 2 6)"
CPU_SOURCE="abgeleitet aus ${HOST_CPUS} Kernen"
else
CPU_SOURCE="vorgegeben"
fi
if [ -z "$VM_MEMORY" ]; then
HOST_MEM_BYTES="$(sysctl -n hw.memsize 2>/dev/null || echo 8589934592)"
HOST_MEM_GB="$(( HOST_MEM_BYTES / 1024 / 1024 / 1024 ))"
# Ein Drittel des RAM. Unter 3 GB laeuft OCR nicht vernuenftig, ueber 8 GB
# bringt es paperless nichts mehr.
VM_MEMORY="$(clamp "$(( HOST_MEM_GB / 3 ))" 3 8)"
MEM_SOURCE="abgeleitet aus ${HOST_MEM_GB} GB RAM"
LOW_MEMORY_HOST="$([ "$HOST_MEM_GB" -lt 8 ] && echo "$HOST_MEM_GB" || true)"
else
MEM_SOURCE="vorgegeben"
fi
if [ -z "$VM_DISK" ]; then
# df -g liefert den freien Platz auf dem Systemvolume in GB.
FREE_GB="$(df -g / 2>/dev/null | awk 'NR==2 {print $4}')"
[ -n "${FREE_GB:-}" ] || FREE_GB=100
# Die Colima-Disk waechst nur nach Bedarf, die Angabe ist eine Obergrenze.
VM_DISK="$(clamp "$(( FREE_GB / 2 ))" 30 100)"
DISK_SOURCE="abgeleitet aus ${FREE_GB} GB frei"
else
DISK_SOURCE="vorgegeben"
fi
CONSUME_DIR="${PAPERLESS_DIR}/consume"
EXPORT_DIR="${PAPERLESS_DIR}/export"
# Alle Nutzdaten liegen bewusst als Bind-Mount auf APFS, damit Time Machine
# sie erfasst - Docker-Volumes liegen im Disk-Image der Colima-VM und waeren
# fuer Time Machine nur ein staendig veraenderter Klumpen.
MEDIA_DIR="${PAPERLESS_DIR}/media"
DATA_DIR="${PAPERLESS_DIR}/data"
PGDATA_DIR="${PAPERLESS_DIR}/pgdata"
BIN_DIR="${PAPERLESS_DIR}/bin"
COMPOSE_FILE="${PAPERLESS_DIR}/docker-compose.yml"
ENV_FILE="${PAPERLESS_DIR}/.env"
APP_ENV_FILE="${PAPERLESS_DIR}/paperless.env"
# ---------------------------------------------------------------------------
# 0. Vorbedingungen
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "yes" ]; then
printf '\n%s### TROCKENLAUF ###%s\n' "$C_YELLOW" "$C_OFF"
info "Es wird nichts installiert, keine VM gestartet, kein Container angelegt."
info "Erzeugt werden ausschliesslich die Konfigurationsdateien unter:"
info " ${PAPERLESS_DIR}"
fi
step "System wird geprueft"
[ "$(uname -s)" = "Darwin" ] || die "Dieses Skript laeuft nur auf macOS."
[ "$(id -u)" -ne 0 ] || die "Bitte NICHT mit sudo starten - Homebrew, Docker und Colima gehoeren dem Benutzer."
MACOS_VERSION="$(sw_vers -productVersion)"
MACOS_MAJOR="${MACOS_VERSION%%.*}"
ARCH="$(uname -m)"
info "macOS ${MACOS_VERSION} auf ${ARCH}"
[ "$MACOS_MAJOR" -ge 13 ] || warn "macOS 13 oder neuer wird empfohlen (gefunden: ${MACOS_VERSION})."
if [ -n "${LOW_MEMORY_HOST:-}" ]; then
warn "Nur ${LOW_MEMORY_HOST} GB RAM im System - paperless bekommt ${VM_MEMORY} GB."
info "OCR kann dabei langsam werden oder fehlschlagen. Empfohlen sind 8 GB oder mehr."
fi
if ! xcode-select -p >/dev/null 2>&1; then
if [ "$DRY_RUN" = "yes" ]; then
skip "Xcode Command Line Tools wuerden installiert (xcode-select --install)."
else
step "Xcode Command Line Tools werden installiert"
warn "Es oeffnet sich ein Dialog. Bitte bestaetigen und warten, bis er durchgelaufen ist."
xcode-select --install || true
until xcode-select -p >/dev/null 2>&1; do sleep 10; done
ok "Command Line Tools vorhanden."
fi
else
info "Xcode Command Line Tools vorhanden."
fi
# ---------------------------------------------------------------------------
# 1. Hilfsfunktionen fuer Rueckfragen und Homebrew
# ---------------------------------------------------------------------------
# Stellt eine Ja/Nein-Frage. Antwortet nur mit "ja", wenn es wirklich ein "ja"
# gibt - ohne Terminal (Cron, CI, Pipe) gilt das als "nein", ausser --yes.
confirm() { # confirm <frage>
local answer
if [ "$ASSUME_YES" = "yes" ]; then
info "$1 -> ja (--yes)"
return 0
fi
if [ ! -t 0 ]; then
warn "Rueckfrage ohne Terminal nicht moeglich: $1"
info "Mit --yes laufen solche Rueckfragen automatisch durch."
return 1
fi
printf ' %s [j/N] ' "$1"
read -r answer
case "$answer" in
[jJyY]|[jJ][aA]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
# Der uebliche Brew-Prefix, auch ohne installiertes Homebrew brauchbar.
case "$ARCH" in
arm64) BREW_PREFIX="/opt/homebrew" ;;
*) BREW_PREFIX="/usr/local" ;;
esac
BREW_READY="no"
# Installiert bzw. aktualisiert Homebrew - aber nur, wenn wirklich etwas
# installiert werden muss. Laeuft paperless auf einem vorhandenen Docker,
# wird Homebrew gar nicht angefasst.
ensure_homebrew() {
[ "$BREW_READY" = "yes" ] && return 0
step "Homebrew"
# Brew ist evtl. installiert, aber nicht im PATH dieser Shell.
if ! command -v brew >/dev/null 2>&1; then
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
[ -x "$candidate" ] && eval "$("$candidate" shellenv)" && break
done
fi
if ! command -v brew >/dev/null 2>&1; then
if [ "$DRY_RUN" = "yes" ]; then
skip "Homebrew ist nicht vorhanden und wuerde jetzt installiert."
info "angenommener Brew-Prefix: ${BREW_PREFIX}"
BREW_READY="yes"
return 0
fi
info "Homebrew ist nicht vorhanden und wird installiert."
info "Der Installer fragt ggf. nach dem Admin-Passwort."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
[ -x "$candidate" ] && eval "$("$candidate" shellenv)" && break
done
command -v brew >/dev/null 2>&1 || die "Homebrew wurde installiert, ist aber nicht im PATH."
BREW_BIN="$(command -v brew)"
for profile in "${HOME}/.zprofile" "${HOME}/.bash_profile"; do
if [ -f "$profile" ] || [ "$profile" = "${HOME}/.zprofile" ]; then
grep -q 'brew shellenv' "$profile" 2>/dev/null \
|| printf '\neval "$(%s shellenv)"\n' "$BREW_BIN" >> "$profile"
fi
done
ok "Homebrew installiert."
else
info "gefunden: $(command -v brew)"
if [ "$DRY_RUN" = "yes" ]; then
skip "brew update"
else
info "Update laeuft (kann einen Moment dauern)..."
brew update
ok "Homebrew aktuell."
fi
fi
BREW_PREFIX="$(brew --prefix)"
BREW_READY="yes"
}
# Installiert Formeln, falls sie fehlen, und haelt vorhandene aktuell.
brew_ensure_formulae() { # brew_ensure_formulae <formel>...
ensure_homebrew
local f missing=()
for f in "$@"; do
if command -v brew >/dev/null 2>&1 && brew list --formula --versions "$f" >/dev/null 2>&1; then
info "vorhanden: $f"
else
missing+=("$f")
fi
done
if [ "$DRY_RUN" = "yes" ]; then
[ ${#missing[@]} -gt 0 ] && skip "brew install --formula ${missing[*]}"
skip "brew upgrade --formula $*"
return 0
fi
if [ ${#missing[@]} -gt 0 ]; then
info "wird installiert: ${missing[*]}"
brew install --formula "${missing[@]}"
fi
brew upgrade --formula "$@" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# 2. Container-Runtime waehlen
# ---------------------------------------------------------------------------
#
# Vorrang hat immer eine bereits vorhandene Docker-Installation. Colima wird
# nur als Rueckfallebene eingerichtet, und auch das erst nach Rueckfrage -
# es ist eine zusaetzliche Linux-VM auf dem Rechner.
#
# Reihenfolge:
# 1. Ein Docker-Daemon ist erreichbar -> benutzen
# 2. Docker Desktop / Rancher Desktop da, aus -> fragen, ob starten
# 3. Colima-Instanz existiert bereits -> benutzen
# 4. Nichts davon -> fragen, ob Colima einrichten
step "Container-Runtime"
RUNTIME="" # "docker" oder "colima"
DOCKER_BIN=""
DOCKER_SOCK="" # nur bei Colima gesetzt
DOCKER_DESKTOP_APP="/Applications/Docker.app"
RANCHER_APP="/Applications/Rancher Desktop.app"
# Sucht eine docker-CLI. Bei vorhandenem Docker Desktop ist dessen Binary die
# richtige Wahl, deshalb hat der PATH hier Vorrang.
find_docker_cli() {
local c
for c in "$(command -v docker 2>/dev/null || true)" \
"${HOME}/.docker/bin/docker" \
/usr/local/bin/docker \
"${BREW_PREFIX}/bin/docker"; do
if [ -n "$c" ] && [ -x "$c" ]; then printf '%s' "$c"; return 0; fi
done
return 1
}
daemon_reachable() {
[ -n "$DOCKER_BIN" ] && "$DOCKER_BIN" info >/dev/null 2>&1
}
# Wartet, bis der Daemon antwortet (Docker Desktop braucht nach dem Start
# typischerweise 20-60 Sekunden).
wait_for_daemon() { # wait_for_daemon <sekunden>
local waited=0 limit="${1:-90}"
while [ "$waited" -lt "$limit" ]; do
daemon_reachable && return 0
sleep 3
waited=$(( waited + 3 ))
[ $(( waited % 15 )) -eq 0 ] && info " ... noch nicht bereit (${waited}s)"
done
return 1
}
# Versucht, eine Desktop-Anwendung als Runtime zu starten.
try_start_app() { # try_start_app <name> <app-pfad>
local name="$1" app="$2"
[ -d "$app" ] || return 1
info "${name} ist installiert, der Daemon laeuft aber nicht."
if [ "$DRY_RUN" = "yes" ]; then
skip "${name} starten (open -a)"
return 1
fi
confirm "${name} jetzt starten?" || return 1
open -a "$app" || { warn "${name} liess sich nicht starten."; return 1; }
info "Warte auf den Daemon..."
if wait_for_daemon 120; then
ok "${name} laeuft."
return 0
fi
warn "${name} wurde gestartet, der Daemon antwortet aber nicht."
return 1
}
DOCKER_BIN="$(find_docker_cli || true)"
if [ "$RUNTIME_CHOICE" != "colima" ] && [ -n "$DOCKER_BIN" ] && daemon_reachable; then
RUNTIME="docker"
ok "Docker laeuft bereits - wird verwendet."
info "CLI: ${DOCKER_BIN}"
info "Kontext: $("$DOCKER_BIN" context show 2>/dev/null || echo unbekannt)"
elif [ "$RUNTIME_CHOICE" != "colima" ] && try_start_app "Docker Desktop" "$DOCKER_DESKTOP_APP"; then
RUNTIME="docker"
elif [ "$RUNTIME_CHOICE" != "colima" ] && try_start_app "Rancher Desktop" "$RANCHER_APP"; then
RUNTIME="docker"
elif [ "$RUNTIME_CHOICE" = "docker" ]; then
die "Mit --runtime docker verlangt, aber kein Docker-Daemon erreichbar."
fi
if [ -z "$RUNTIME" ]; then
# Rueckfallebene Colima. Eine bereits eingerichtete Instanz gilt als
# Zustimmung - dann wurde die Frage frueher schon einmal bejaht.
COLIMA_BIN="${BREW_PREFIX}/bin/colima"
[ -x "$COLIMA_BIN" ] || COLIMA_BIN="$(command -v colima 2>/dev/null || true)"
# colima list fuehrt auch gestoppte Instanzen auf - im Gegensatz zu
# "colima status", das nur bei laufenden Instanzen erfolgreich ist.
COLIMA_EXISTS="no"
if [ -n "$COLIMA_BIN" ] && "$COLIMA_BIN" list 2>/dev/null \
| awk -v p="$COLIMA_PROFILE" 'NR>1 && $1==p {found=1} END {exit !found}'; then
COLIMA_EXISTS="yes"
fi
if [ "$COLIMA_EXISTS" = "yes" ]; then
info "Colima-Instanz \"${COLIMA_PROFILE}\" ist bereits eingerichtet."
RUNTIME="colima"
else
echo
if [ "$RUNTIME_CHOICE" = "colima" ]; then
info "Colima wurde per --runtime colima angefordert."
else
info "Es wurde keine laufende Docker-Installation gefunden."
fi
info "Einrichten von Colima bedeutet:"
info " - Homebrew wird installiert bzw. aktualisiert"
info " - die Formeln colima, docker und docker-compose werden installiert"
info " - eine Linux-VM mit ${VM_CPU} CPUs, ${VM_MEMORY} GB RAM und ${VM_DISK} GB Disk"
info " wird als eigene Instanz \"${COLIMA_PROFILE}\" angelegt"
echo
if [ "$DRY_RUN" = "yes" ]; then
skip "Rueckfrage, ob Colima eingerichtet werden soll (im Trockenlauf als ja angenommen)"
RUNTIME="colima"
elif confirm "Colima jetzt einrichten und verwenden?"; then
RUNTIME="colima"
else
echo
info "Abgebrochen - es wurde nichts installiert."
info "Alternativen:"
info " - Docker Desktop installieren: https://www.docker.com/products/docker-desktop/"
info " - danach dieses Skript erneut ausfuehren"
exit 0
fi
fi
fi
# Was die Wahl konkret bedeutet
if [ "$RUNTIME" = "colima" ]; then
# Colima braucht die reine docker-CLI aus Homebrew - nicht das Cask, das
# waere Docker Desktop.
brew_ensure_formulae colima docker docker-compose
if [ "$DRY_RUN" = "yes" ]; then
skip "Symlink ~/.docker/cli-plugins/docker-compose"
else
# docker-compose als CLI-Plugin verfuegbar machen.
mkdir -p "${HOME}/.docker/cli-plugins"
COMPOSE_BIN="${BREW_PREFIX}/opt/docker-compose/bin/docker-compose"
[ -x "$COMPOSE_BIN" ] || COMPOSE_BIN="${BREW_PREFIX}/bin/docker-compose"
[ -x "$COMPOSE_BIN" ] || die "docker-compose wurde nicht gefunden."
ln -sfn "$COMPOSE_BIN" "${HOME}/.docker/cli-plugins/docker-compose"
fi
# Bei Colima bewusst der feste Homebrew-Pfad: Docker Desktop und Rancher
# Desktop legen eigene "docker"-Binaries in den PATH und schatten die von
# Homebrew, und sie setzen einen eigenen aktiven Kontext. Die Container
# wuerden sonst in der falschen VM landen.
DOCKER_BIN="${BREW_PREFIX}/bin/docker"
[ -x "$DOCKER_BIN" ] || [ "$DRY_RUN" = "yes" ] \
|| die "docker-CLI nicht unter ${DOCKER_BIN} gefunden."
info "Docker-CLI: ${DOCKER_BIN}"
else
# Vorhandenes Docker: "docker compose" bringt Docker Desktop selbst mit.
if [ "$DRY_RUN" != "yes" ] && ! "$DOCKER_BIN" compose version >/dev/null 2>&1; then
warn "Die vorhandene docker-CLI kennt kein \"compose\"."
if confirm "docker-compose per Homebrew nachinstallieren?"; then
brew_ensure_formulae docker-compose
mkdir -p "${HOME}/.docker/cli-plugins"
COMPOSE_BIN="${BREW_PREFIX}/opt/docker-compose/bin/docker-compose"
[ -x "$COMPOSE_BIN" ] || COMPOSE_BIN="${BREW_PREFIX}/bin/docker-compose"
ln -sfn "$COMPOSE_BIN" "${HOME}/.docker/cli-plugins/docker-compose"
"$DOCKER_BIN" compose version >/dev/null 2>&1 \
|| die "\"docker compose\" funktioniert weiterhin nicht."
else
die "Ohne \"docker compose\" kann paperless nicht eingerichtet werden."
fi
fi
fi
ok "Runtime: ${RUNTIME}"
# Alle Compose-Aufrufe laufen ueber diesen Helfer.
dc() {
"$DOCKER_BIN" compose --project-directory "$PAPERLESS_DIR" -p "$COMPOSE_PROJECT" "$@"
}
# ---------------------------------------------------------------------------
# 3. Deinstallation (braucht die Werkzeuge von oben)
# ---------------------------------------------------------------------------
if [ "$DO_UNINSTALL" = "yes" ]; then
step "Deinstallation"
info "Entfernt werden die Container und die Broker-Queue."
info "Dokumente, Datenbank und Konfiguration liegen als Bind-Mount auf der"
info "Platte und bleiben unter ${PAPERLESS_DIR} erhalten."
printf ' Zum Fortfahren "loeschen" eingeben: '
read -r CONFIRM
[ "$CONFIRM" = "loeschen" ] || die "Abgebrochen. Es wurde nichts geaendert."
if [ -f "$COMPOSE_FILE" ]; then
if [ "$RUNTIME" = "colima" ]; then
export DOCKER_HOST="unix://${HOME}/.colima/${COLIMA_PROFILE}/docker.sock"
fi
dc down --volumes || true
fi
ok "Container entfernt."
if [ "$RUNTIME" = "colima" ]; then
info "Die Colima-Instanz laeuft weiter: colima stop --profile ${COLIMA_PROFILE}"
fi
echo
info "Daten wirklich loeschen - das ist unwiderruflich:"
info " rm -rf \"${PAPERLESS_DIR}\""
exit 0
fi
# ---------------------------------------------------------------------------
# 4. Verzeichnisse
# ---------------------------------------------------------------------------
step "Verzeichnisstruktur unter ${PAPERLESS_DIR}"
mkdir -p "$PAPERLESS_DIR" "$CONSUME_DIR" "$EXPORT_DIR" "$BIN_DIR" \
"$MEDIA_DIR" "$DATA_DIR" "$PGDATA_DIR"
# PostgreSQL verweigert den Start, wenn das Datenverzeichnis fuer andere
# lesbar ist. 0700 setzen, bevor der Container es das erste Mal anfasst.
chmod 700 "$PGDATA_DIR"
ok "Verzeichnisse angelegt."
# ---------------------------------------------------------------------------
# 5. Container-Runtime bereitstellen
# ---------------------------------------------------------------------------
if [ "$RUNTIME" = "colima" ]; then
step "Colima-VM"
info "Eigene Instanz: Profil \"${COLIMA_PROFILE}\" (vorhandene Colima-Profile bleiben unberuehrt)"
# --arch host wird bewusst gesetzt: erbt das Profil je eine abweichende
# Architektur, versucht Colima sonst zu emulieren und verlangt QEMU.
COLIMA_ARGS=(
--profile "$COLIMA_PROFILE"
--arch host
--cpu "$VM_CPU"
--memory "$VM_MEMORY"
--disk "$VM_DISK"
--mount "${PAPERLESS_DIR}:w"
)
# Auf Apple Silicon ab macOS 13 ist das Apple-Virtualization-Framework mit
# virtiofs deutlich schneller als der QEMU-Default.
if [ "$ARCH" = "arm64" ] && [ "$MACOS_MAJOR" -ge 13 ]; then
COLIMA_ARGS+=(--vm-type vz --mount-type virtiofs)
fi
if [ "$DRY_RUN" = "yes" ]; then
info "Die VM wuerde angelegt mit:"
info " ${VM_CPU} CPUs (${CPU_SOURCE})"
info " ${VM_MEMORY} GB RAM (${MEM_SOURCE})"
info " ${VM_DISK} GB Disk (${DISK_SOURCE}, waechst nur nach Bedarf)"
skip "colima start ${COLIMA_ARGS[*]}"
skip "LaunchAgent fuer den Autostart"
skip "Erreichbarkeitspruefung ueber den Colima-Socket"
elif colima status --profile "$COLIMA_PROFILE" >/dev/null 2>&1; then
info "Instanz \"${COLIMA_PROFILE}\" laeuft bereits."
# Pruefen, ob das Installationsverzeichnis in der VM beschreibbar ist.
if ! colima ssh --profile "$COLIMA_PROFILE" -- test -w "$CONSUME_DIR" 2>/dev/null; then
warn "${CONSUME_DIR} ist in der Colima-VM nicht beschreibbar."
info "Instanz mit passendem Mount neu starten:"
info " colima stop --profile ${COLIMA_PROFILE}"
info " colima start --profile ${COLIMA_PROFILE} --mount \"${PAPERLESS_DIR}:w\""
die "Bitte obige Befehle ausfuehren und das Skript erneut starten."
fi
ok "VM erreichbar, Mounts in Ordnung."
else
info "VM wird gestartet mit:"
info " ${VM_CPU} CPUs (${CPU_SOURCE})"
info " ${VM_MEMORY} GB RAM (${MEM_SOURCE})"
info " ${VM_DISK} GB Disk (${DISK_SOURCE}, waechst nur nach Bedarf)"
info "Beim ersten Mal wird ein Linux-Image geladen - das dauert ein paar Minuten."
colima start "${COLIMA_ARGS[@]}"
ok "Colima-Instanz \"${COLIMA_PROFILE}\" laeuft."
fi
if [ "$DRY_RUN" != "yes" ]; then
# Verbindung ueber den Socket der eigenen Instanz, nicht ueber den aktiven
# Docker-Kontext - der zeigt auf Rechnern mit Docker Desktop oder Rancher
# Desktop woandershin.
DOCKER_SOCK="${HOME}/.colima/${COLIMA_PROFILE}/docker.sock"
if [ ! -S "$DOCKER_SOCK" ]; then
# Falls Colima die Pfadkonvention aendert: aus der Statusausgabe holen.
DOCKER_SOCK="$(colima status --profile "$COLIMA_PROFILE" --json 2>/dev/null \
| tr ',' '\n' | sed -n 's|.*"\(/[^"]*docker\.sock\)".*|\1|p' | head -1)"
fi
[ -S "$DOCKER_SOCK" ] || die "Docker-Socket der Instanz \"${COLIMA_PROFILE}\" nicht gefunden."
export DOCKER_HOST="unix://${DOCKER_SOCK}"
info "Docker-Socket: ${DOCKER_SOCK}"
"$DOCKER_BIN" info >/dev/null 2>&1 \
|| die "Docker erreicht die Instanz \"${COLIMA_PROFILE}\" nicht. Pruefen: colima status --profile ${COLIMA_PROFILE}"
# Autostart. "brew services start colima" wuerde das Standardprofil starten,
# nicht unseres - deshalb ein eigener LaunchAgent.
AGENT_LABEL="org.paperlessngx.colima"
AGENT_PLIST="${HOME}/Library/LaunchAgents/${AGENT_LABEL}.plist"
mkdir -p "${HOME}/Library/LaunchAgents"
cat > "$AGENT_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${AGENT_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${BREW_PREFIX}/bin/colima</string>
<string>start</string>
<string>--profile</string>
<string>${COLIMA_PROFILE}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>${PAPERLESS_DIR}/colima-autostart.log</string>
<key>StandardErrorPath</key>
<string>${PAPERLESS_DIR}/colima-autostart.log</string>
</dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)/${AGENT_LABEL}" 2>/dev/null || true
if launchctl bootstrap "gui/$(id -u)" "$AGENT_PLIST" 2>/dev/null; then
ok "Autostart aktiviert (Instanz startet beim Anmelden)."
else
warn "Autostart konnte nicht registriert werden."
info "Manuell: colima start --profile ${COLIMA_PROFILE}"
fi
fi
else
# Vorhandenes Docker: kein eigener Socket, kein Autostart, keine VM-Groesse.
# Der Daemon wurde in Abschnitt 2 bereits als erreichbar geprueft.
step "Container-Runtime"
info "Vorhandenes Docker wird verwendet - keine zusaetzliche VM noetig."
if [ "$DRY_RUN" != "yes" ]; then
info "Kontext: $("$DOCKER_BIN" context show 2>/dev/null || echo unbekannt)"
fi
fi
# ---------------------------------------------------------------------------
# 6. Secrets
# ---------------------------------------------------------------------------
step "Konfiguration"
# Erzeugt eine zufaellige alphanumerische Zeichenkette.
#
# Bewusst NICHT als "tr -dc ... < /dev/urandom | head -c N": head schliesst die
# Pipe, sobald es genug Bytes hat, tr bekommt SIGPIPE und endet mit 141. Unter
# "set -o pipefail" ist damit die ganze Pipeline fehlerhaft und "set -e" bricht
# das Skript ab - reproduzierbar bei jeder Neuinstallation.
rand_string() {
local n="${1:-32}" out="" chunk="" tries=0
while [ "${#out}" -lt "$n" ]; do
tries=$(( tries + 1 ))
[ "$tries" -gt 20 ] && die "Zufallswerte konnten nicht erzeugt werden - ist openssl vorhanden?"
chunk="$(openssl rand -base64 48 2>/dev/null | tr -d '/+=[:space:]')" || chunk=""
out="${out}${chunk}"
done
printf '%s' "${out:0:$n}"
}
if [ -f "$ENV_FILE" ]; then
info "Bestehende Secrets werden uebernommen: ${ENV_FILE}"
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
else
POSTGRES_PASSWORD="$(rand_string 32)"
PAPERLESS_SECRET_KEY="$(rand_string 64)"
PAPERLESS_ADMIN_PASSWORD="$(rand_string 20)"
cat > "$ENV_FILE" <<EOF
# Zugangsdaten fuer die paperless-ngx Installation. Nur fuer den Besitzer lesbar.
# Diese Datei sichern - ohne sie ist die Datenbank nicht mehr zu oeffnen.
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
PAPERLESS_ADMIN_USER=${ADMIN_USER}
PAPERLESS_ADMIN_PASSWORD=${PAPERLESS_ADMIN_PASSWORD}
NEW_INSTALL=yes
EOF
chmod 600 "$ENV_FILE"
ok "Secrets erzeugt: ${ENV_FILE}"
fi
# Das paperless-Image bringt eng, deu, ita, spa und fra bereits mit. Nur was
# darueber hinausgeht, muss ueber PAPERLESS_OCR_LANGUAGES beim Start des
# Containers nachinstalliert werden - das kostet bei jedem Start Zeit.
BUILTIN_LANGS="eng deu ita spa fra"
EXTRA_LANGS=""
for lang in $(printf '%s' "$OCR_LANGUAGES" | tr '+' ' '); do
case " ${BUILTIN_LANGS} " in
*" ${lang} "*) ;;
*) EXTRA_LANGS="${EXTRA_LANGS}${EXTRA_LANGS:+ }${lang}" ;;
esac
done
if [ -n "$EXTRA_LANGS" ]; then
OCR_LANGUAGES_LINE="PAPERLESS_OCR_LANGUAGES=${EXTRA_LANGS}"
info "Zusaetzlich nachzuinstallierende Tesseract-Sprachen: ${EXTRA_LANGS}"
else
OCR_LANGUAGES_LINE="# PAPERLESS_OCR_LANGUAGES= (nicht noetig, alle gewaehlten Sprachen sind im Image)"
fi
# Anwendungseinstellungen - hier darf der Benutzer gefahrlos nachjustieren.
if [ -f "$APP_ENV_FILE" ]; then
info "Bestehende Anwendungskonfiguration wird beibehalten: ${APP_ENV_FILE}"
else
cat > "$APP_ENV_FILE" <<EOF
# paperless-ngx Anwendungseinstellungen
# Alle Optionen: https://docs.paperless-ngx.com/configuration/
# Nach Aenderungen: ${BIN_DIR}/paperlessctl restart
PAPERLESS_TIME_ZONE=${TIME_ZONE}
PAPERLESS_OCR_LANGUAGE=${OCR_LANGUAGES}
${OCR_LANGUAGES_LINE}
# Modi ab paperless 3.0: auto (Default), redo, force, off
PAPERLESS_OCR_MODE=auto
PAPERLESS_OCR_CLEAN=clean
# Der consume-Ordner liegt auf dem Mac und wird in die VM gemountet.
# Dateisystem-Events kommen dort nicht zuverlaessig an, deshalb Polling.
PAPERLESS_CONSUMER_POLLING=10
PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=5
PAPERLESS_CONSUMER_POLLING_DELAY=5
PAPERLESS_CONSUMER_RECURSIVE=true
PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=true
# .DS_Store, ._* und Thumbs.db ignoriert paperless bereits eingebaut.
# Hier nur die restlichen macOS-Eigenheiten - als Regex, per re.match auf den
# reinen Dateinamen angewendet (nicht auf den Pfad).
PAPERLESS_CONSUMER_IGNORE_PATTERNS=["^\\\\.localized\$", "^\\\\.Spotlight-V100", "^\\\\.Trashes", "^\\\\.fseventsd", "^\\\\.TemporaryItems", "^\\\\.apdisk\$", "^\\\\.stfolder", "^\\\\.stversions"]
# Ab paperless 3.0 Jinja-Syntax mit doppelten geschweiften Klammern.
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }}
# Fuer Zugriff von anderen Geraeten: hier die vollstaendige URL eintragen,
# sonst schlaegt die CSRF-Pruefung beim Anmelden fehl.
# PAPERLESS_URL=http://mein-mac.local:${WEB_PORT}
EOF
ok "Anwendungskonfiguration erstellt: ${APP_ENV_FILE}"
fi
# ---------------------------------------------------------------------------
# 7. Compose-Datei
# ---------------------------------------------------------------------------
cat > "$COMPOSE_FILE" <<EOF
# Automatisch erzeugt von install-paperless-macos.sh - Aenderungen gehen bei
# einem erneuten Lauf des Skripts verloren.
# Anwendungseinstellungen gehoeren in paperless.env, Secrets in .env
services:
broker:
image: ${BROKER_IMAGE}
container_name: paperless-broker
restart: unless-stopped
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
db:
image: ${POSTGRES_IMAGE}
container_name: paperless-db
restart: unless-stopped
volumes:
# Bind-Mount auf APFS, damit Time Machine die Datenbank mitsichert.
# postgres:18 legt die Daten unter /var/lib/postgresql/18/docker ab,
# deshalb wird das Elternverzeichnis gemountet, nicht .../data.
- ${PGDATA_DIR}:/var/lib/postgresql
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"]
interval: 10s
timeout: 5s
retries: 10
webserver:
image: ${PAPERLESS_IMAGE}:${IMAGE_TAG}
container_name: paperless-webserver
restart: unless-stopped
depends_on:
db:
condition: service_healthy
broker:
condition: service_healthy
ports:
- "${BIND_ADDR}:${WEB_PORT}:8000"
volumes:
- ${DATA_DIR}:/usr/src/paperless/data
- ${MEDIA_DIR}:/usr/src/paperless/media
- ${CONSUME_DIR}:/usr/src/paperless/consume
- ${EXPORT_DIR}:/usr/src/paperless/export
env_file:
- ${APP_ENV_FILE}
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBENGINE: postgresql
PAPERLESS_DBHOST: db
PAPERLESS_DBPORT: 5432
PAPERLESS_DBNAME: paperless
PAPERLESS_DBUSER: paperless
PAPERLESS_DBPASS: \${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env}
PAPERLESS_SECRET_KEY: \${PAPERLESS_SECRET_KEY:?PAPERLESS_SECRET_KEY fehlt in .env}
# Wird nur beim allerersten Start ausgewertet und legt das Admin-Konto an.
PAPERLESS_ADMIN_USER: \${PAPERLESS_ADMIN_USER:-admin}
PAPERLESS_ADMIN_PASSWORD: \${PAPERLESS_ADMIN_PASSWORD:-}
healthcheck:
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
volumes:
# Nur noch die Broker-Queue als Volume - das ist fluechtiger Arbeitszustand
# ohne Sicherungswert. Alles andere liegt als Bind-Mount unter ${PAPERLESS_DIR}.
redisdata:
EOF
ok "docker-compose.yml geschrieben."
# ---------------------------------------------------------------------------
# 8. Verwaltungswerkzeug
# ---------------------------------------------------------------------------
cat > "${BIN_DIR}/paperlessctl" <<EOF
#!/bin/bash
# Verwaltung der paperless-ngx Installation unter ${PAPERLESS_DIR}
set -euo pipefail
DIR="${PAPERLESS_DIR}"
PROJECT="${COMPOSE_PROJECT}"
PROFILE="${COLIMA_PROFILE}"
RUNTIME="${RUNTIME}"
DOCKER="${DOCKER_BIN}"
COLIMA="${BREW_PREFIX}/bin/colima"
# Bei Colima fest ueber den Socket der eigenen Instanz: Docker Desktop und
# Rancher Desktop bringen eigene "docker"-Binaries mit und setzen den aktiven
# Kontext auf ihre eigene VM. Bei vorhandenem Docker gilt dessen Kontext.
if [ "\$RUNTIME" = "colima" ]; then
export DOCKER_HOST="unix://\${HOME}/.colima/\${PROFILE}/docker.sock"
fi
dc() { "\$DOCKER" compose --project-directory "\$DIR" -p "\$PROJECT" "\$@"; }
# Sorgt dafuer, dass die Runtime laeuft.
ensure_vm() {
if [ "\$RUNTIME" = "colima" ]; then
if ! "\$COLIMA" status --profile "\$PROFILE" >/dev/null 2>&1; then
echo "Colima-Instanz \"\$PROFILE\" wird gestartet..."
"\$COLIMA" start --profile "\$PROFILE"
fi
return
fi
"\$DOCKER" info >/dev/null 2>&1 && return
if [ -d /Applications/Docker.app ]; then
echo "Docker-Daemon laeuft nicht - Docker Desktop wird gestartet..."
open -a /Applications/Docker.app
for _ in \$(seq 1 40); do
"\$DOCKER" info >/dev/null 2>&1 && return
sleep 3
done
fi
echo "Docker-Daemon ist nicht erreichbar. Bitte Docker starten." >&2
exit 1
}
usage() {
cat <<'USAGE'
paperlessctl <befehl>
start Runtime und alle Container starten
stop Container stoppen (Runtime laeuft weiter)
restart Container neu starten
status Status von Runtime und Containern
logs [dienst] Logs folgen (webserver|db|broker, Default: alle)
update Neue Images holen und Container erneuern
backup Dokumente + Metadaten nach export/ schreiben
manage ... Django-Kommando, z.B. "paperlessctl manage createsuperuser"
shell Shell im paperless-Container
psql psql-Sitzung in der Datenbank
repair-password Datenbank-Passwort wieder mit .env in Einklang bringen.
Noetig, wenn pgdata aus einer frueheren Installation stammt
und paperless mit "password authentication failed" abbricht.
USAGE
}
cmd="\${1:-}"; shift || true
case "\$cmd" in
start) ensure_vm; dc up -d ;;
stop) dc stop ;;
restart) ensure_vm; dc up -d --force-recreate ;;
status)
if [ "\$RUNTIME" = "colima" ]; then
"\$COLIMA" status --profile "\$PROFILE" || true
else
echo "Runtime: docker (\$("\$DOCKER" context show 2>/dev/null || echo '?'))"
fi
echo
dc ps
;;
logs)
if [ \$# -gt 0 ]; then dc logs -f --tail 100 "\$@"; else dc logs -f --tail 100; fi
;;
update)
ensure_vm
dc pull
dc up -d
"\$DOCKER" image prune -f >/dev/null 2>&1 || true
echo "Update abgeschlossen."
;;
backup)
dc exec -T webserver document_exporter ../export --delete --no-progress-bar
echo "Export liegt in ${EXPORT_DIR}"
echo "Ebenfalls sichern: ${ENV_FILE} (enthaelt die Schluessel)"
;;
repair-password)
PW="\$(sed -n 's/^POSTGRES_PASSWORD=//p' "\${DIR}/.env" | head -1)"
[ -n "\$PW" ] || { echo "POSTGRES_PASSWORD fehlt in \${DIR}/.env" >&2; exit 1; }
# Ueber den lokalen Socket gilt "trust" - das alte Passwort wird nicht gebraucht.
printf "ALTER USER paperless PASSWORD '%s';\n" "\${PW//\\'/\\'\\'}" \\
| dc exec -T db psql -U paperless -d paperless -q
echo "Passwort abgeglichen. Webserver wird neu gestartet..."
dc restart webserver
;;
manage) dc exec webserver python3 manage.py "\$@" ;;
shell) dc exec webserver bash ;;
psql) dc exec db psql -U paperless -d paperless ;;
*)
usage
[ -z "\$cmd" ] && exit 0 || exit 1
;;
esac
EOF
chmod +x "${BIN_DIR}/paperlessctl"
ok "Verwaltungswerkzeug: ${BIN_DIR}/paperlessctl"
# ---------------------------------------------------------------------------
# 9. Images holen und starten
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "yes" ]; then
step "Was ein echter Lauf jetzt noch tun wuerde"
skip "docker compose pull (ca. 1,5 GB: paperless-ngx, postgres, valkey)"
skip "docker compose up -d (broker, db, webserver)"
skip "Warten auf http://${BIND_ADDR}:${WEB_PORT}/accounts/login/"
cat <<EOF
${C_GREEN}Trockenlauf abgeschlossen.${C_OFF} Es wurde nichts installiert und nichts gestartet.
Erzeugte Dateien:
${COMPOSE_FILE}
${APP_ENV_FILE}
${ENV_FILE}
${BIN_DIR}/paperlessctl
Pruefen laesst sich das Ergebnis ohne laufende VM mit:
${DOCKER_BIN} compose --project-directory "${PAPERLESS_DIR}" config
Echter Lauf: dasselbe Kommando ohne --dry-run.
EOF
exit 0
fi
if [ "$START_STACK" != "yes" ]; then
cat <<EOF
${C_GREEN}Vorbereitung abgeschlossen.${C_OFF} Die Container wurden auf Wunsch nicht gestartet.
Start mit: ${BIN_DIR}/paperlessctl start
EOF
exit 0
fi
step "Container-Images werden geladen"
info "Beim ersten Mal werden ca. 1,5 GB heruntergeladen."
dc pull
ok "Images vorhanden."
step "Container werden gestartet"
dc up -d
ok "Container gestartet."
# Die Datenbank liegt als Bind-Mount auf APFS und wird per virtiofs in die
# Colima-VM gereicht. Wenn PostgreSQL dabei ueber Eigentuemer oder Rechte
# stolpert, scheitert es hier - und zwar bevor paperless ueberhaupt startet.
step "Datenbank wird geprueft"
DB_OK="no"
for _ in $(seq 1 60); do
if dc exec -T db pg_isready -U paperless -d paperless >/dev/null 2>&1; then
DB_OK="yes"; break
fi
sleep 2
done
if [ "$DB_OK" = "yes" ]; then
ok "PostgreSQL laeuft auf dem Bind-Mount unter ${PGDATA_DIR}"
# Passwort des Datenbankbenutzers mit .env abgleichen.
#
# PostgreSQL wertet POSTGRES_PASSWORD ausschliesslich bei der Erst-
# initialisierung aus. Bleibt pgdata bestehen, waehrend .env neu erzeugt
# wird - etwa weil nur ein Teil der Installation geloescht wurde - traegt
# der Cluster weiter das alte Passwort, und paperless scheitert mit
# "password authentication failed". Seit pgdata ein Bind-Mount ist, kann
# das leicht passieren: die Daten ueberleben jedes Aufraeumen im
# Installationsverzeichnis.
#
# Der Abgleich ist idempotent und laeuft ueber den lokalen Unix-Socket,
# fuer den in der Standard-pg_hba.conf "trust" gilt - also ohne Kenntnis
# des alten Passworts. Uebergabe per stdin, damit das Passwort nicht in
# der Prozessliste des Containers auftaucht.
# Einfache Anfuehrungszeichen verdoppeln, falls jemand .env von Hand
# bearbeitet hat - die generierten Passwoerter sind rein alphanumerisch.
if printf "ALTER USER paperless PASSWORD '%s';\n" "${POSTGRES_PASSWORD//\'/\'\'}" \
| dc exec -T db psql -U paperless -d paperless -q >/dev/null 2>&1; then
ok "Datenbank-Passwort mit .env abgeglichen."
else
warn "Passwort konnte nicht abgeglichen werden."
info "Bei Fehlern der Form 'password authentication failed' hilft:"
info " ${BIN_DIR}/paperlessctl repair-password"
fi
else
warn "PostgreSQL ist nach 2 Minuten nicht bereit."
info "Das ist die erwartete Schwachstelle des Bind-Mounts fuer PGDATA:"
info "PostgreSQL besteht auf Eigentuemer und Rechten am Datenverzeichnis,"
info "die ueber virtiofs von APFS nicht zwingend durchgereicht werden."
echo
info "Letzte Zeilen aus dem Datenbank-Log:"
echo
dc logs --tail 25 db 2>&1 | sed 's/^/ /' || true
echo
die "Start abgebrochen. Bitte obige Ausgabe pruefen."
fi
step "Warte auf paperless-ngx"
info "Der erste Start dauert laenger - Datenbank und Migrationen werden angelegt."
READY="no"
for _ in $(seq 1 180); do
if curl -fsS -o /dev/null "http://127.0.0.1:${WEB_PORT}/accounts/login/" 2>/dev/null; then
READY="yes"; break
fi
sleep 2
done
if [ "$READY" = "yes" ]; then
ok "paperless-ngx antwortet."
else
warn "paperless-ngx antwortet noch nicht (Timeout nach 6 Minuten)."
info "Logs ansehen: ${BIN_DIR}/paperlessctl logs webserver"
fi
# ---------------------------------------------------------------------------
# Zusammenfassung
# ---------------------------------------------------------------------------
SHOW_CREDENTIALS="no"
grep -q '^NEW_INSTALL=yes' "$ENV_FILE" 2>/dev/null && SHOW_CREDENTIALS="yes"
cat <<EOF
${C_GREEN}Fertig.${C_OFF} paperless-ngx laeuft (Runtime: ${RUNTIME}).
Weboberflaeche http://${BIND_ADDR}:${WEB_PORT}
Dokumente rein ${CONSUME_DIR}
Einstellungen ${APP_ENV_FILE}
Zugangsdaten ${ENV_FILE}
Alle Nutzdaten liegen auf der Platte, nicht in Docker-Volumes - Time Machine
sichert sie damit im normalen Lauf mit:
Dokumente ${MEDIA_DIR}
Suchindex u.a. ${DATA_DIR}
Datenbank ${PGDATA_DIR}
${C_YELLOW}Wichtig zur Sicherung:${C_OFF} Ein Time-Machine-Abzug von ${PGDATA_DIR}
entsteht waehrend die Datenbank laeuft und ist deshalb nicht garantiert
wiederherstellbar. Fuer eine verlaessliche Sicherung zusaetzlich regelmaessig:
${BIN_DIR}/paperlessctl backup
Das schreibt Dokumente und Metadaten konsistent nach ${EXPORT_DIR}.
EOF
if [ "$SHOW_CREDENTIALS" = "yes" ]; then
# Marker entfernen, damit das Passwort nicht bei jedem Lauf erneut erscheint.
ADMIN_PW="$(sed -n 's/^PAPERLESS_ADMIN_PASSWORD=//p' "$ENV_FILE" | head -1)"
sed -i '' '/^NEW_INSTALL=yes$/d' "$ENV_FILE"
cat <<EOF
${C_YELLOW}Zugangsdaten fuer die erste Anmeldung:${C_OFF}
Benutzer: ${ADMIN_USER}
Passwort: ${ADMIN_PW}
Das Passwort steht auch in ${ENV_FILE}.
Aendern laesst es sich in der Weboberflaeche oder mit:
${BIN_DIR}/paperlessctl manage changepassword ${ADMIN_USER}
EOF
fi
cat <<EOF
Verwaltung:
${BIN_DIR}/paperlessctl status
${BIN_DIR}/paperlessctl logs webserver
${BIN_DIR}/paperlessctl update
${BIN_DIR}/paperlessctl backup
${C_DIM}Tipp: fuer bequemen Zugriff in den PATH aufnehmen
echo 'export PATH="${BIN_DIR}:\$PATH"' >> ~/.zprofile${C_OFF}
Erneutes Ausfuehren dieses Skripts aktualisiert die Installation.
Entfernen: $0 --uninstall
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment