Selbst hosten
ohanly auf deinem eigenen Server
Fertige Docker-Images, eine Compose-Datei, PostgreSQL inklusive. Eure Daten verlassen euren Server nicht, und es braucht kein Abo. Die Einrichtung dauert etwa zehn Minuten.
Voraussetzungen
-
Ein Rechner, der dauerhaft läuft: Heimserver, NAS, Raspberry Pi 4 oder 5, ein kleiner
VPS. Die Images gibt es für
amd64undarm64. - Docker mit Compose v2 (
docker compose versionzeigt es an). - Für Passkeys und die Installation als App auf dem Handy: eine eigene Domain mit HTTPS. Im Heimnetz geht es auch ohne, dann ohne diese beiden Funktionen.
Installation
Du brauchst nur zwei Dateien: compose.yaml beschreibt die Dienste,
.env enthält deine Einstellungen.
1. Verzeichnis anlegen
mkdir -p /opt/ohanly && cd /opt/ohanly
2. Datei compose.yaml anlegen
Öffne eine neue Datei, z. B. mit nano compose.yaml, füge den folgenden Inhalt
ein und speichere sie (in nano: Strg+O, Enter,
Strg+X).
compose.yaml
# ohanly – Self-Hosting mit fertigen Images (ohne Quellcode auf dem Server)
#
# Anleitung: https://ohanly.cloud/selbst-hosten/
#
# 1. Diese Datei als compose.yaml in ein Verzeichnis legen, z. B. /opt/ohanly
# 2. .env anlegen (Block aus der Anleitung oder ./init-env.sh); FH_PUBLIC_URL anpassen
# 3. docker compose up -d → db → migrate → api + worker → web
# 4. docker compose logs api | grep Setup-Token → erstes Konto unter <FH_PUBLIC_URL>/setup
#
# Update: docker compose pull && docker compose up -d (vorher Backup, siehe Anleitung)
name: ohanly
x-api-image: &api-image
image: ${OHANLY_IMAGE_PREFIX:-ghcr.io/akandor/ohanly}-api:${OHANLY_VERSION:-latest}
# Härtung: kein Schreibzugriff aufs Image, keine Linux-Capabilities, keine Rechteausweitung
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
services:
db:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_DB: familyhub
POSTGRES_USER: familyhub_owner
POSTGRES_PASSWORD: ${FH_DB_OWNER_PASSWORD:?FH_DB_OWNER_PASSWORD fehlt – ./init-env.sh ausführen}
volumes:
- pgdata:/var/lib/postgresql
# Kein Port nach außen: nur die Dienste dieses Projekts erreichen die Datenbank
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U familyhub_owner -d familyhub']
interval: 5s
timeout: 3s
retries: 20
migrate:
<<: *api-image
command: ['node', 'src/cli.ts', 'migrate']
environment:
DATABASE_MIGRATION_URL: postgres://familyhub_owner:${FH_DB_OWNER_PASSWORD}@db:5432/familyhub
FH_DB_APP_PASSWORD: ${FH_DB_APP_PASSWORD:?FH_DB_APP_PASSWORD fehlt}
depends_on:
db:
condition: service_healthy
restart: 'no'
api:
<<: *api-image
restart: unless-stopped
environment: &api-env
DATABASE_URL: postgres://familyhub_app:${FH_DB_APP_PASSWORD}@db:5432/familyhub
FH_PUBLIC_URL: ${FH_PUBLIC_URL:?FH_PUBLIC_URL fehlt – Adresse, unter der Browser die App öffnen}
FH_REGISTRATION_MODE: ${FH_REGISTRATION_MODE:-setup}
# Anfragen kommen über Caddy (1 Hop) – nötig für Rate-Limits je Client-IP
FH_TRUST_PROXY: ${FH_TRUST_PROXY:-1}
LOG_LEVEL: ${LOG_LEVEL:-info}
FH_ENCRYPTION_KEYS: ${FH_ENCRYPTION_KEYS:-}
FH_GOOGLE_CLIENT_ID: ${FH_GOOGLE_CLIENT_ID:-}
FH_GOOGLE_CLIENT_SECRET: ${FH_GOOGLE_CLIENT_SECRET:-}
FH_GOOGLE_OAUTH: ${FH_GOOGLE_OAUTH:-true}
FH_CALENDAR_ALLOWED_PRIVATE_HOSTS: ${FH_CALENDAR_ALLOWED_PRIVATE_HOSTS:-}
FH_RECIPE_IMPORT: ${FH_RECIPE_IMPORT:-false}
FH_HOLIDAYS: ${FH_HOLIDAYS:-true}
# Problem melden: Feedback-Dienst des Herausgebers (AD-27); leer = aus
FH_FEEDBACK_URL: ${FH_FEEDBACK_URL:-}
FH_FEEDBACK_KEY: ${FH_FEEDBACK_KEY:-}
# Betreiber-Kennzahlen unter /stats (docs/operations.md § 3); leer = aus
FH_OPERATOR_TOKEN: ${FH_OPERATOR_TOKEN:-}
FH_SMTP_HOST: ${FH_SMTP_HOST:-}
FH_SMTP_PORT: ${FH_SMTP_PORT:-}
FH_SMTP_SECURITY: ${FH_SMTP_SECURITY:-}
FH_SMTP_USER: ${FH_SMTP_USER:-}
FH_SMTP_PASSWORD: ${FH_SMTP_PASSWORD:-}
FH_MAIL_FROM: ${FH_MAIL_FROM:-}
# Neue Konten müssen die E-Mail-Adresse bestätigen (braucht SMTP)
FH_REQUIRE_EMAIL_VERIFICATION: ${FH_REQUIRE_EMAIL_VERIFICATION:-false}
# Mitteilungen (docs/operations.md § 2): App-Mitteilungen über das Push-Gateway (off = aus),
# Web Push braucht keine Einstellung
FH_PUSH_GATEWAY_URL: ${FH_PUSH_GATEWAY_URL:-}
FH_VAPID_PRIVATE_KEY: ${FH_VAPID_PRIVATE_KEY:-}
FH_PUSH_CONTACT: ${FH_PUSH_CONTACT:-}
FH_PUSH_ALLOWED_HOSTS: ${FH_PUSH_ALLOWED_HOSTS:-}
# Kein Port nach außen: Browser und Apps sprechen mit `web` (/api wird weitergeleitet)
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/health/ready']
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
worker:
<<: *api-image
restart: unless-stopped
command: ['node', 'src/worker.ts']
environment: *api-env
depends_on:
migrate:
condition: service_completed_successfully
# Web-App (PWA) + Reverse-Proxy: alles unter einer Adresse (Cookies, CSRF, Live-Updates)
web:
image: ${OHANLY_IMAGE_PREFIX:-ghcr.io/akandor/ohanly}-web:${OHANLY_VERSION:-latest}
restart: unless-stopped
environment:
# ":80" hinter einem eigenen Proxy (Traefik, nginx, Synology …) oder eine Domain wie
# "familie.example.org" – dann holt Caddy selbst ein Zertifikat (Ports 80/443 unten)
FH_SITE_ADDRESS: ${FH_SITE_ADDRESS:-:80}
FH_TRUSTED_PROXIES: ${FH_TRUSTED_PROXIES:-}
# Optional: ganze App hinter Basic Auth (Hash base64-kodiert, siehe Anleitung)
FH_BASIC_AUTH_USER: ${FH_BASIC_AUTH_USER:-}
FH_BASIC_AUTH_HASH: ${FH_BASIC_AUTH_HASH:-}
read_only: true
tmpfs: [/config]
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt: ['no-new-privileges:true']
ports:
- '${FH_WEB_PORT:-8080}:80'
# Eigene Domain mit automatischem HTTPS: FH_WEB_PORT=80 setzen und diese Zeile aktivieren
# - '443:443'
volumes:
- caddy_data:/data
depends_on:
api:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:80/']
interval: 10s
timeout: 3s
retries: 5
volumes:
pgdata:
caddy_data:
3. Datei .env anlegen
Kopiere diesen Block ins Terminal und drücke Enter. Er legt die
.env an und setzt Datenbank-Passwörter und den Verschlüsselungsschlüssel auf
Zufallswerte. Eine vorhandene .env bleibt unangetastet.
Terminal
test -e .env || cat > .env <<EOF
# ohanly – Konfiguration für deploy/compose.yaml
# ./init-env.sh legt daraus .env an und füllt Passwörter und Schlüssel zufällig aus.
# Passwörter nur aus [A-Za-z0-9] wählen – sie stehen in Verbindungs-URLs.
# Image-Version: latest oder fest, z. B. 2026.10.05.42 (Anleitung: https://ohanly.cloud/selbst-hosten/)
OHANLY_VERSION=latest
# Eigene Registry oder Fork: <registry>/<besitzer>/ohanly (-api / -web werden angehängt)
OHANLY_IMAGE_PREFIX=ghcr.io/akandor/ohanly
# Datenbank: Owner (Migrationen) und Laufzeitrolle der API (unterliegt RLS)
FH_DB_OWNER_PASSWORD=$(openssl rand -hex 24)
FH_DB_APP_PASSWORD=$(openssl rand -hex 24)
# Adresse, unter der Browser die App öffnen – exakt (CSRF-Prüfung, Links in Einladungen).
# https → sichere Cookies und Passkeys.
FH_PUBLIC_URL=http://ohanly.local:8080
FH_WEB_PORT=8080
# Eigene Domain mit automatischem HTTPS durch Caddy: FH_SITE_ADDRESS=familie.example.org,
# FH_PUBLIC_URL=https://familie.example.org, FH_WEB_PORT=80 und Port 443 in compose.yaml
FH_SITE_ADDRESS=:80
# Hinter einem eigenen Reverse-Proxy (Traefik, nginx, Synology …): dessen IP/CIDR oder private_ranges
FH_TRUSTED_PROXIES=
# Optional: ganze App hinter Basic Auth (z. B. vor dem Start). Hash erzeugen:
# docker run --rm caddy:2-alpine caddy hash-password --plaintext 'passwort' | base64
# Leer = aus. Der Paddle-Webhook bleibt offen; die iOS-App funktioniert dahinter nicht.
FH_BASIC_AUTH_USER=
FH_BASIC_AUTH_HASH=
# setup | invite_only | open (setup: erstes Konto nur mit Setup-Token aus dem Log)
FH_REGISTRATION_MODE=setup
# Verschlüsselung der Kalender-Zugangsdaten und Zwei-Faktor-Geheimnisse (AES-256-GCM).
# Getrennt vom Backup sicher aufbewahren!
FH_ENCRYPTION_KEYS=k$(date +%Y%m%d):$(openssl rand -base64 32)
# Google-Kalender per OAuth (optional)
FH_GOOGLE_CLIENT_ID=
FH_GOOGLE_CLIENT_SECRET=
# „Mit Google verbinden“ abschalten, ohne den Client zu entfernen (false = keine neuen
# Verbindungen; bestehende synchronisieren weiter)
FH_GOOGLE_OAUTH=true
# CalDAV/iCal im eigenen Netz (z. B. Nextcloud im LAN): Hosts oder CIDRs, kommagetrennt
FH_CALENDAR_ALLOWED_PRIVATE_HOSTS=
# Rezept-Import per Link (standardmäßig aus; manche Portale untersagen automatisches Auslesen)
FH_RECIPE_IMPORT=false
# Feiertagskalender von der OpenHolidays API (false = aus, keine Verbindung nach außen)
FH_HOLIDAYS=true
# Mitteilungen (Push). Im Browser und in der installierten Web-App ohne Einstellung (Web Push,
# Schlüssel erzeugt der Server selbst). Die Apps bekommen Mitteilungen über das Push-Gateway
# von ohanly – der Inhalt ist für jedes Gerät verschlüsselt. off = keine App-Mitteilungen.
FH_PUSH_GATEWAY_URL=
# Kontakt für Push-Dienste (mailto:… oder https://…); Standard: FH_PUBLIC_URL
FH_PUSH_CONTACT=
# E-Mail-Versand (optional): Passwort vergessen und Einladungen per Mail
# FH_SMTP_SECURITY: starttls (Port 587), tls (Port 465) oder none (nur lokales Relay)
FH_SMTP_HOST=
FH_SMTP_PORT=
FH_SMTP_SECURITY=starttls
FH_SMTP_USER=
FH_SMTP_PASSWORD=
FH_MAIL_FROM="ohanly <[email protected]>"
# Neue Konten müssen ihre E-Mail-Adresse bestätigen, bevor sie eine Familie anlegen
# (z. B. bei offener Registrierung). Braucht den E-Mail-Versand oben.
FH_REQUIRE_EMAIL_VERIFICATION=false
LOG_LEVEL=info
EOF
chmod 600 .env
Öffne die Datei danach mit nano .env und trage bei
FH_PUBLIC_URL die Adresse ein, unter der ihr ohanly im Browser öffnet –
welche das ist, steht im nächsten Abschnitt.
4. Starten
docker compose up -d
docker compose ps
Beim ersten Start richtet sich die Datenbank ein. Nach etwa einer Minute stehen
api und web auf „healthy“.
Adresse und HTTPS
FH_PUBLIC_URL muss genau die Adresse sein, die im Browser steht – sonst lehnt
ohanly Anmeldungen aus Sicherheitsgründen ab. Wähle die Variante, die zu deinem Server
passt, und starte danach mit docker compose up -d neu.
- Hinter einem vorhandenen Reverse-Proxy
-
Traefik, nginx, Caddy, Synology, Unraid … Den Proxy auf
http://<server>:8080zeigen lassen.FH_PUBLIC_URL=https://familie.example.org FH_TRUSTED_PROXIES=private_rangesStatt
private_rangesgeht auch die genaue IP des Proxys. - ohanly holt das Zertifikat selbst
-
Der Server ist direkt aus dem Internet erreichbar, die Domain zeigt auf ihn. Zusätzlich in
compose.yamldie Zeile- '443:443'aktivieren.FH_SITE_ADDRESS=familie.example.org FH_PUBLIC_URL=https://familie.example.org FH_WEB_PORT=80 - Nur im Heimnetz
-
FH_PUBLIC_URL=http://192.168.1.20:8080Mit der IP oder dem Namen deines Servers. Passkeys und App-Installation stehen dann nicht zur Verfügung.
Erstes Konto
Solange es noch kein Konto gibt, schreibt ohanly ein einmaliges Setup-Token ins Protokoll.
Damit legst du unter <FH_PUBLIC_URL>/setup das erste Konto an – die
Anmeldeseite leitet bei einer frischen Installation von selbst dorthin.
docker compose logs api | grep Setup-Token
Danach kommen weitere Personen nur per Einladung dazu. Kinderprofile brauchen kein eigenes Konto.
E-Mail, Kalender und Rezepte (optional)
Alles Folgende steht in .env; nach einer Änderung
docker compose up -d.
Für „Passwort vergessen“ und Einladungen per E-Mail. Ohne E-Mail funktioniert ohanly trotzdem – Einladungen lassen sich dann als Link weitergeben.
FH_SMTP_HOST=smtp.example.org
FH_SMTP_PORT=587
FH_SMTP_SECURITY=starttls
[email protected]
FH_SMTP_PASSWORD=…
FH_MAIL_FROM="ohanly <[email protected]>"
Damit die Mails nicht im Spam landen, beim Mail-Anbieter SPF und DKIM für die Absender-Domain einrichten.
Google Kalender
- In der Google Cloud Console ein Projekt anlegen, die Google Calendar API aktivieren und einen OAuth-Client vom Typ „Webanwendung“ erstellen.
-
Als Weiterleitungs-URI eintragen:
<FH_PUBLIC_URL>/api/v1/integrations/google/callback. Google akzeptiert dafür nur HTTPS-Domains undlocalhost, keine IP-Adressen im Heimnetz. -
Client-ID und Secret eintragen:
FH_GOOGLE_CLIENT_ID=… FH_GOOGLE_CLIENT_SECRET=… - Den Zustimmungsbildschirm auf „In Produktion“ stellen. Im Status „Testen“ laufen die Verbindungen nach 7 Tagen ab. Der Hinweis „nicht überprüfte App“ beim Verbinden ist für die private Nutzung normal.
Ohne eigene Domain bindest du Google-Kalender stattdessen über ihre private iCal-Adresse ein (nur lesend).
iCloud, Nextcloud und andere CalDAV-Server
Funktionieren ohne weitere Einrichtung. Läuft der Kalender-Server bei dir im Heimnetz, gib ihn ausdrücklich frei – ohanly ruft sonst keine privaten Adressen auf:
FH_CALENDAR_ALLOWED_PRIVATE_HOSTS=nextcloud.fritz.box,192.168.1.30
Rezepte per Link übernehmen
FH_RECIPE_IMPORT=true
ohanly liest dann die Rezeptdaten einer Seite aus. Manche Rezeptportale untersagen das automatische Auslesen in ihren Nutzungsbedingungen – deshalb ist es standardmäßig aus.
Update
Vorher immer ein Backup anlegen. Danach die neuen Images laden und neu starten:
docker compose exec -T db pg_dump -U familyhub_owner -d familyhub -Fc > backup-$(date +%F).dump
docker compose pull
docker compose up -d
Die Datenbank wird beim Start automatisch auf den neuen Stand gebracht. Mit
OHANLY_VERSION=latest (Standard) bekommst du immer die neueste Version. Wer
Updates bewusst einspielen will, setzt eine feste Version, z. B.
OHANLY_VERSION=2026.10.05.42, und ändert sie für das Update. Zurück auf eine
ältere Version geht es nur über ein Backup.
Backup und Wiederherstellung
Backup
docker compose exec -T db pg_dump -U familyhub_owner -d familyhub -Fc > backup.dump
Zum Beispiel täglich per Cronjob. Bewahre die Datei .env getrennt und sicher
auf: Ohne den Schlüssel darin lassen sich Kalenderverbindungen und
Zwei-Faktor-Einstellungen aus einem Backup nicht mehr lesen. Backups enthalten persönliche
Daten eurer Familie – verschlüsselt ablegen.
Wiederherstellen
docker compose stop api worker web
docker compose exec -T db dropdb -U familyhub_owner --if-exists --force familyhub
docker compose exec -T db createdb -U familyhub_owner familyhub
docker compose exec -T db pg_restore -U familyhub_owner -d familyhub --no-owner --role=familyhub_owner < backup.dump
docker compose run --rm migrate
docker compose up -d
Wenn etwas hakt
- Anmelden schlägt fehl, obwohl das Passwort stimmt
-
FH_PUBLIC_URLpasst nicht genau zur Adresse im Browser – Schema (http/https), Domain und Port müssen übereinstimmen. - Profilbilder lassen sich nicht hochladen
-
Im Protokoll steht „Profilbilder deaktiviert“: Der Prozessor bietet kein x86-64-v2.
Meist ist es eine VM mit generischem CPU-Typ – in Proxmox den Prozessortyp auf
hoststellen und die VM einmal ganz herunterfahren. Alles andere läuft auch so. - Passwort vergessen, aber kein E-Mail-Versand eingerichtet
-
Einen Link zum Zurücksetzen erzeugen:
docker compose exec api node src/cli.ts reset-password [email protected] - Vor dem Start nicht öffentlich zeigen
-
Die ganze App lässt sich zusätzlich hinter ein Passwort des Browsers legen. Hash
erzeugen und mit
FH_BASIC_AUTH_USERin.enveintragen:docker run --rm caddy:2-alpine caddy hash-password --plaintext 'passwort' | base64 # Ausgabe als FH_BASIC_AUTH_HASH=… eintragen - Protokolle ansehen
-
docker compose logs -f api web