Self-hosting
ohanly on your own server
Ready-made Docker images, one Compose file, PostgreSQL included. Your data never leaves your server, and no subscription is needed. Setup takes about ten minutes.
Requirements
-
A computer that runs all the time: home server, NAS, Raspberry Pi 4 or 5, a small VPS.
The images are available for
amd64andarm64. - Docker with Compose v2 (
docker compose versionshows it). - For passkeys and installing it as an app on your phone: your own domain with HTTPS. On a home network it works without, just without those two features.
Installation
You only need two files: compose.yaml describes the services,
.env holds your settings.
1. Create a directory
mkdir -p /opt/ohanly && cd /opt/ohanly
2. Create compose.yaml
Open a new file, e.g. with nano compose.yaml, paste the following content and
save it (in nano: Ctrl+O, Enter,
Ctrl+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. Create .env
Paste this block into the terminal and press Enter. It creates the
.env and sets the database passwords and the encryption key to random values.
An existing .env is left untouched. (The comments in the file are in German.)
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
Then open the file with nano .env and set FH_PUBLIC_URL to the
address you’ll open ohanly at in the browser – the next section explains which one.
4. Start
docker compose up -d
docker compose ps
On first start the database sets itself up. After about a minute api and
web show “healthy”.
Address and HTTPS
FH_PUBLIC_URL must be exactly the address shown in the browser – otherwise
ohanly rejects sign-ins for security reasons. Pick the option that fits your server, then
restart with docker compose up -d.
- Behind an existing reverse proxy
-
Traefik, nginx, Caddy, Synology, Unraid … Point the proxy at
http://<server>:8080.FH_PUBLIC_URL=https://family.example.org FH_TRUSTED_PROXIES=private_rangesInstead of
private_rangesyou can also use the proxy’s exact IP. - ohanly gets the certificate itself
-
The server is reachable directly from the internet and the domain points to it. Also enable the line
- '443:443'incompose.yaml.FH_SITE_ADDRESS=family.example.org FH_PUBLIC_URL=https://family.example.org FH_WEB_PORT=80 - Home network only
-
FH_PUBLIC_URL=http://192.168.1.20:8080With your server’s IP or name. Passkeys and app installation aren’t available then.
First account
As long as there is no account yet, ohanly writes a one-time setup token to the log. Use
it to create the first account at <FH_PUBLIC_URL>/setup – on a fresh
installation the sign-in page takes you there by itself.
docker compose logs api | grep Setup-Token
After that, more people only join by invitation. Child profiles don’t need their own account.
Email, calendars and recipes (optional)
All of the following goes in .env; after a change run
docker compose up -d.
For “forgot password” and invitations by email. ohanly works without email too – invitations can then be shared as a link.
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]>"
So the emails don’t end up in spam, set up SPF and DKIM for the sender domain with your mail provider.
Google Calendar
- In the Google Cloud Console create a project, enable the Google Calendar API and create an OAuth client of type “Web application”.
-
Add this redirect URI:
<FH_PUBLIC_URL>/api/v1/integrations/google/callback. Google only accepts HTTPS domains andlocalhosthere, no IP addresses on your home network. -
Enter client ID and secret:
FH_GOOGLE_CLIENT_ID=… FH_GOOGLE_CLIENT_SECRET=… - Set the consent screen to “In production”. In “Testing” status connections expire after 7 days. The “unverified app” notice when connecting is normal for private use.
Without your own domain, connect Google calendars via their secret iCal address instead (read only).
iCloud, Nextcloud and other CalDAV servers
Work without further setup. If the calendar server runs on your home network, allow it explicitly – otherwise ohanly doesn’t contact private addresses:
FH_CALENDAR_ALLOWED_PRIVATE_HOSTS=nextcloud.fritz.box,192.168.1.30
Importing recipes from a link
FH_RECIPE_IMPORT=true
ohanly then reads the recipe data from a page. Some recipe sites forbid automated reading in their terms of use – that’s why it’s off by default.
Updates
Always make a backup first. Then pull the new images and restart:
docker compose exec -T db pg_dump -U familyhub_owner -d familyhub -Fc > backup-$(date +%F).dump
docker compose pull
docker compose up -d
The database is migrated automatically on start. With
OHANLY_VERSION=latest (the default) you always get the newest version. To
install updates deliberately, set a fixed version, e.g.
OHANLY_VERSION=2026.10.05.42, and change it for the update. Going back to an
older version only works via a backup.
Backup and restore
Backup
docker compose exec -T db pg_dump -U familyhub_owner -d familyhub -Fc > backup.dump
For example daily via cron. Keep the .env file separately and safe: without
the key in it, calendar connections and two-factor settings can’t be read from a backup.
Backups contain your family’s personal data – store them encrypted.
Restore
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
Troubleshooting
- Sign-in fails although the password is right
-
FH_PUBLIC_URLdoesn’t exactly match the address in the browser – scheme (http/https), domain and port must match. - Profile pictures can’t be uploaded
-
The log says “Profilbilder deaktiviert” (profile pictures disabled): the processor
doesn’t offer x86-64-v2. Usually it’s a VM with a generic CPU type – in Proxmox set
the CPU type to
hostand shut the VM down fully once. Everything else works anyway. - Forgot password, but no email set up
-
Create a reset link:
docker compose exec api node src/cli.ts reset-password [email protected] - Keep it private before launch
-
The whole app can additionally sit behind a browser password. Create a hash and add it
with
FH_BASIC_AUTH_USERto.env:docker run --rm caddy:2-alpine caddy hash-password --plaintext 'password' | base64 # Put the output in FH_BASIC_AUTH_HASH=… - View the logs
-
docker compose logs -f api web