Self-Hosting

Self-Hosting FamilyHub

FamilyHub is fully open source. You can run it on your own server and keep full control of your data. One Docker Compose file brings up everything — the app, the database, the calendar server, and HTTPS.

Source code: gitlab.com/yvanpersonal/familyhub — MIT license.

What you need

  • A Linux server with Docker (Docker Compose v2 is included in current Docker). A VPS, a mini-PC, a NAS, or an old laptop all work. About 2 GB of RAM is enough.
  • A domain name, for example familyhub.example.com, with a DNS A record pointing to your server's IP address.
  • Ports 80 and 443 open to the internet. On a home server, that means forwarding these two ports on your router to the server.

No domain? It also runs on just your home network — see Without a domain below.

Install in four steps

1. Get the code

git clone https://gitlab.com/yvanpersonal/familyhub.git
cd familyhub

2. Fill in your settings

cp .env.example .env

Open .env and fill in three values:

SettingWhat to put there
FAMILYHUB_DOMAINYour domain, e.g. familyhub.example.com
JWT_SECRETThe output of openssl rand -base64 32
ENCRYPTION_KEYThe output of another openssl rand -base64 32 — a different value

Or generate both secrets in one go:

sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$(openssl rand -base64 32)|; s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=$(openssl rand -base64 32)|" .env

Keep a copy of .env somewhere safe. The API keys you save in FamilyHub are encrypted with ENCRYPTION_KEY; without it they can't be read back.

3. Start it

docker compose up -d

The first start downloads the images and takes a few minutes. Meanwhile, Caddy gets an HTTPS certificate for your domain from Let's Encrypt. Check that everything is up:

docker compose ps

All services should be running (the API shows healthy once it's ready). radicale-init runs once and then stops — that's expected.

4. Open FamilyHub

Go to https://familyhub.example.com. You're asked to create the Super Admin account. After that, create your family and invite everyone — see Getting started.

That's it.

What's running

ServiceWhat it does
caddyThe only thing reachable from outside (ports 80 and 443). Handles HTTPS and forwards to the app.
webThe FamilyHub app
apiThe backend
postgresThe database
radicaleThe calendar server (CalDAV)

Postgres and Radicale only listen on the server itself (127.0.0.1), never on the internet.

Without a domain (home network only)

Leave FAMILYHUB_DOMAIN empty in .env. FamilyHub then runs on plain http://<server-ip> — for example http://192.168.1.10.

This works, with two limits:

  • No voice input and no "install as app". Browsers only allow the microphone and app installation over HTTPS. Everything else works.
  • Only at home. Phones can only reach FamilyHub on your home Wi-Fi.

Optional: the calendar on your phone

The family calendar works inside FamilyHub without any of this. These steps are only needed to sync it with the calendar app on your phone or computer (CalDAV).

  1. Add a second DNS record, e.g. caldav.example.com, pointing to the same server.
  2. In .env, set:
    CALDAV_DOMAIN=caldav.example.com
    CALDAV_PASSWORD=   # the output of: openssl rand -hex 24
    
    This puts the calendar server on the internet, so the password is required — the stack won't start without one.
  3. Run docker compose up -d again.
  4. In FamilyHub, Admin > Services > CalDAV shows the server address, the username and (after you reveal it) the password for your family. See Calendar for setting up iPhone, Mac and Android.

Optional: the AI assistant

Each family adds its own key under Admin > Services (AI and Speech-to-Text): an Anthropic or OpenAI key. To use one key for every family on your server, put it in .env instead (ANTHROPIC_API_KEY, WHISPER_API_KEY) and run docker compose up -d.

Fully local, no data leaving your network: pick the OpenAI provider and set its API URL to any OpenAI-compatible server — such as Ollama, LM Studio or vLLM — plus the model name. How well the assistant understands you depends on the model you run. For speech-to-text, run faster-whisper (see the reference section at the end) and point the Speech-to-Text URL at it.

Updating

cd familyhub
git pull
docker compose pull
docker compose up -d

Database changes are applied automatically when the new version starts. To stay on a specific version instead of the latest, set FAMILYHUB_VERSION in .env (e.g. 0.0.47).

Backups

Three things hold your data:

WhatHow to back it up
.envCopy the file. Contains ENCRYPTION_KEY.
The databasedocker compose exec -T postgres pg_dump -U familyhub familyhub > familyhub.sql
The calendarThe radicale-data Docker volume and the radicale/users file

To restore, set up a fresh install with your old .env, but start only the database before loading the dump:

docker compose up -d postgres
docker compose exec -T postgres psql -U familyhub familyhub < familyhub.sql
docker compose up -d

For a simple per-family copy, Admin > Data > Export downloads everything for one family as a JSON file — see Admin.

Raspberry Pi and other ARM machines

The published images are for regular (x86-64) servers. On a Raspberry Pi 4/5 or another ARM machine, build the images on the device itself by adding --build:

docker compose up -d --build

To update, use git pull followed by that same command — skip docker compose pull, which would swap your build for the x86-64 images.

The first build takes a while and works best with 4 GB of RAM or more. After that, starting is as quick as anywhere else.

Troubleshooting

  • See what's going on: docker compose logs -f caddy (HTTPS) or docker compose logs -f api (the app).
  • No HTTPS certificate: Caddy's log says why. Almost always the DNS record doesn't point to this server yet, or ports 80/443 aren't reachable from the internet.
  • The stack won't start and mentions JWT_SECRET or ENCRYPTION_KEY: one of them is empty in .env.
  • radicale-init failed: you set CALDAV_DOMAIN without a CALDAV_PASSWORD.
  • Port 80 is already in use (common on a NAS): without a domain, change "80:80" under caddy in docker-compose.yml to e.g. "8080:80" and open http://<server-ip>:8080. With a domain, Caddy needs ports 80 and 443 to get its certificate.

Self-hosted vs. managed

Both run the same code. Self-hosted FamilyHub has every feature unlocked and no billing: the Stripe payment module switches itself off when no STRIPE_SECRET_KEY is set. The startup log confirms it:

FamilyHub payment module: DISABLED (self-hosted / OSS mode). All features unlocked, no billing UI, BYOK for AI/STT unless managed-AI env vars are set.

Moving between the two is an export and an import (Admin > Data). FamilyHub follows each family's language — Dutch and English today, see Multilingual support.


Reference: running it your own way

Everything below is for people who don't use the included Compose file — for example on Kubernetes, or behind a reverse proxy they already run. The Compose setup above handles all of it for you.

The images

FamilyHub ships as plain OCI images that run as non-root and listen on port 8080:

  • registry.gitlab.com/yvanpersonal/familyhub/familyhub-api
  • registry.gitlab.com/yvanpersonal/familyhub/familyhub-web

Tags: latest and one per release (e.g. 0.0.47). The web image serves only the app; your reverse proxy must route /api and /ws to the API (see nginx.compose.conf in the repo for an example) and forward WebSocket upgrades on /ws.

Configuration

All API settings are environment variables, defined in familyhub-api/src/main/resources/application.yml.

Required

VariableDefaultWhat it does
JWT_SECRET(required)Signs login tokens. openssl rand -base64 32
ENCRYPTION_KEY(required)Encrypts saved API keys and service settings (AES-256). openssl rand -base64 32
DB_HOST / DB_PORT / DB_NAMElocalhost / 5432 / familyhubPostgreSQL (version 18)
DB_USER / DB_PASSWORDfamilyhub / familyhubPostgreSQL credentials — change them when the database is reachable by anything else

Server & security

VariableDefaultWhat it does
SERVER_PORT8080API port
BASE_URLhttp://localhost:8080Public URL of FamilyHub, used in links it hands out (calendar subscriptions). Set it to https://your-domain
CORS_ORIGINShttp://localhost:5173,http://localhost:3000Extra allowed origins. Not needed when the app and /api share one domain
COOKIE_SECUREfalseForces Secure on login cookies. Without it they're Secure whenever the request arrives over HTTPS, so behind a TLS proxy that sends X-Forwarded-Proto you can leave it alone

CalDAV (shared calendar)

VariableDefaultWhat it does
CALDAV_URLhttp://localhost:5232Radicale URL the backend uses (usually an internal address)
CALDAV_PUBLIC_URL(empty)Radicale URL shown to families for phone sync, e.g. https://caldav.example.com
CALDAV_USERNAMElocalBackend admin user for MKCALENDAR/DELETE — must match the admin rule in Radicale's rights file
CALDAV_PASSWORDlocalBackend admin password. Change it whenever Radicale is reachable from outside
CALDAV_HTPASSWD_PATH(empty)Radicale's htpasswd file on shared storage. Required for per-family logins; if unset, CalDAV falls back to the shared admin login

AI assistant

Set these for one key across all families, or leave them empty and let each family add its own under Admin > Services. A family's own settings always win.

VariableDefaultWhat it does
ANTHROPIC_API_KEY(empty)Anthropic API key for Claude
WHISPER_URLhttps://api.openai.comSpeech-to-text endpoint (OpenAI Whisper or faster-whisper)
WHISPER_API_KEY(empty)API key for that endpoint

Payment module (managed hosting only)

Leave these unset for a self-hosted install.

VariableDefaultWhat it does
FAMILYHUB_PAYMENT_ENABLED(auto)true / false to force payments on or off. Unset = on only when STRIPE_SECRET_KEY is set
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET(empty)Stripe API and webhook secrets
STRIPE_FULL_PRICE_ID / STRIPE_FULL_PRICE_ID_ANNUAL / STRIPE_BYOK_PRICE_ID / STRIPE_BYOK_PRICE_ID_ANNUAL(empty)Stripe price IDs

Health checks & monitoring

  • Health check: GET /api/health
  • Prometheus metrics: GET /actuator/prometheus (on the API container directly — not routed through the web app)

Radicale (CalDAV)

Radicale is the calendar server behind the shared family calendar. The repo's radicale/ folder has the config and rights files the Compose setup uses; reuse them when you run Radicale yourself.

docker run -d \
  -p 5232:5232 \
  -v /data/radicale:/data \
  -v $(pwd)/radicale/config:/config/config:ro \
  -v $(pwd)/radicale/rights:/etc/radicale/rights:ro \
  -v $(pwd)/radicale/users:/etc/radicale/users:rw \
  tomsquest/docker-radicale

The calendar syncs with iOS and macOS (native), Android (via DAVx5), and Proton and Google Calendar (ICS feed).

Expose Radicale so phones can reach it

Phones connect over the internet, not your internal network. Give Radicale a public hostname (e.g. caldav.example.com) through your reverse proxy, with a valid TLS certificate — iOS and macOS reject self-signed ones. Don't add proxy-level basic auth: Radicale does its own per-family auth (see below), and families can't answer two login prompts.

Radicale must run its own auth — getting this wrong opens your calendar to the internet

The trap: the tomsquest/docker-radicale image reads its config from /config/config. If you mount your config anywhere else (a common mistake is /etc/radicale/config, Radicale's documented default), Radicale silently falls back to its built-in defaults — which are [auth] type=none. Anyone who knows a family UUID can then read and write its calendar.

Check the container logs at startup — you should see:

[INFO] Loaded config file '/config/config'
[INFO] auth type is 'radicale.auth.htpasswd'
[INFO] Read content of htpasswd file done ...
[INFO] rights type is 'radicale.rights.from_file'

If instead you see:

[WARNING] No user authentication is selected: '[auth] type=none' (INSECURE)
[INFO] rights type is 'radicale.rights.owner_only'

stop and fix the mount before exposing Radicale.

Your /config/config should look like this:

[server]
hosts = 0.0.0.0:5232

[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/users
htpasswd_encryption = bcrypt

[storage]
filesystem_folder = /data/collections

[rights]
type = from_file
file = /etc/radicale/rights

Rights rules for Apple CalDAV discovery

FamilyHub creates each family's calendar at /family-<uuid>/events/, directly under that user's Radicale principal URL (/family-<uuid>/). Apple's CalDAV discovery (RFC 6764) walks the principal tree, so the calendar must live there.

Your /etc/radicale/rights needs three rules:

# Backend admin — MKCALENDAR, DELETE, and cross-family admin operations.
# The username here must match CALDAV_USERNAME.
[local-admin]
user: local
collection: .*
permissions: RrWw

# Apple/iOS CalDAV discovery PROPFINDs / for current-user-principal.
# Read-only on the empty path — no data is exposed, just the principal pointer.
[discovery-root]
user: family-(.+)
collection:
permissions: R

# Each family-<uuid> gets full access to its own principal tree only.
# Cross-family access (family-A trying /family-B/...) is denied.
[principal]
user: family-(.+)
collection: family-{0}(/.*)?
permissions: RrWw

The family-<uuid> htpasswd entries are written by the FamilyHub backend when CALDAV_HTPASSWD_PATH points at a file that Radicale also reads. The backend rewrites and chmods that file, so it must be owned by the API's user (UID 65534) and readable by Radicale (UID 2999); the Compose setup's radicale-init service does exactly that.

Verifying the setup

After startup, test from outside your network:

# Anonymous must be 401, not 200.
curl -i -X PROPFIND https://caldav.example.com/ -H 'Depth: 0'

# Wrong password must be 401.
curl -i -X PROPFIND https://caldav.example.com/ -u 'family-<uuid>:wrong'

# Family credentials must succeed (207) on their own path.
curl -i -X PROPFIND https://caldav.example.com/family-<uuid>/events/ \
  -u 'family-<uuid>:<revealed-password>' -H 'Depth: 0'

# Family credentials must get 403 on another family's path.
curl -i -X PROPFIND https://caldav.example.com/family-<other-uuid>/ \
  -u 'family-<uuid>:<revealed-password>'

All four must hold. If anonymous returns 200 or 207, Radicale isn't enforcing auth — go back to the "config at /config/config" step above.

faster-whisper (voice input)

For speech-to-text that stays on your own network:

docker run -d \
  -p 9000:9000 \
  fedirz/faster-whisper-server:latest \
  --model large-v3 \
  --language nl

Add --gpus all if you have a GPU — transcription gets much faster. Without a GPU, consider --model medium.

Then point FamilyHub at it:

  • In the app: Admin > Services > Speech-to-Text → set the URL to http://your-server:9000
  • Or for every family: WHISPER_URL=http://your-server:9000

Kubernetes

FamilyHub runs on any Kubernetes distribution (including k3s, k0s and MicroK8s), with your own manifests, Helm chart or GitOps flow. Things to know:

  • The images run as non-root (the API as UID 65534)
  • PostgreSQL and Radicale need persistent volumes (any storage class)
  • The ingress must route /api and /ws to the API, forward WebSocket upgrades on /ws, and send X-Forwarded-Proto
  • No custom operators, CRDs, or cluster-scoped resources are needed

Developing FamilyHub

Building and testing FamilyHub itself (JDK 24, Node 22) is covered in the README.

Questions or issues?

Open an issue on GitLab or check the documentation.