Self-hosting
Hearth runs as three containers behind any reverse proxy that can terminate TLS: the API, Postgres, and MinIO for profile pictures. A Raspberry Pi 4 or the smallest VPS you can rent is plenty for a family.
Requirements
- Docker Engine 24+ with Compose v2 (
docker compose version) - A domain or subdomain pointing at the host, e.g.
hearth.example.com, or a path on a domain you already use, e.g.lab.example.com/hearth - Ports 80/443 reachable, for the reverse proxy
Location updates are small, a few hundred bytes every 30 to 60 seconds per phone, so bandwidth won't be what runs out. Disk might. Measured over 200,000 rows, a stored fix costs about 340 bytes with indexes. At one fix a minute that comes to roughly 14 MB per phone at the default 30 day retention, or 170 MB for every year of history you keep.
1. Configure
The server is published as a Docker image, dhqureshi/hearth-api, built for
amd64 and arm64. You do not need the repository to run Hearth. Make a folder,
put this in docker-compose.yml, and put a .env beside it:
name: hearth
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: hearth
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: hearth
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hearth -d hearth"]
interval: 10s
timeout: 5s
retries: 10
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY}
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 12
api:
image: dhqureshi/hearth-api:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
env_file: [.env]
environment:
NODE_ENV: production
DATABASE_URL: postgres://hearth:${POSTGRES_PASSWORD}@db:5432/hearth
S3_ENDPOINT: ${S3_ENDPOINT-http://minio:9000}
ports:
# Loopback only, for the reverse proxy on this machine. Docker's port
# rules go around ufw and firewalld.
- "127.0.0.1:4000:4000"
volumes:
postgres-data:
minio-data:
The .env needs at least these. Compose reads it twice: once to substitute the
variables above, and once to hand the whole file to the API.
JWT_SECRET= # openssl rand -base64 48, paste the output
PUBLIC_URL=https://hearth.example.com # where phones will reach you
POSTGRES_PASSWORD=some-long-random-string # openssl rand -hex 24, it goes into a URL
ADMIN_EMAIL=you@example.com # required, your account
ADMIN_PASSWORD=a-long-passphrase # required, at least 10 characters
ADMIN_NAME=Your Name # optional, defaults to the part before the @
S3_ACCESS_KEY_ID=hearth # MinIO, for profile pictures
S3_SECRET_ACCESS_KEY=another-long-random-string
Beyond those, .env.example in the repository carries the variables most
deployments touch, each with its default, and none of that remainder has to be
set to boot. server/src/env.ts is the full list of everything the server
parses, which is where to look for anything .env.example does not name.
The server refuses to start in production without ADMIN_EMAIL and
ADMIN_PASSWORD. That account is created once, while the database still has no
users, and it is the only way in. Registration has no exemption for the first
account, so a server you have not signed into yet cannot be claimed by whoever
finds the URL first.
Editing ADMIN_PASSWORD later does nothing, because the bootstrap only runs
against an empty user table. Change the password from the app.
Leave REGISTRATION_MODE=invite (the default) unless you want anyone who finds
the URL to be able to sign up. Everyone else joins with an invite you send them.
An admin can change this later from the app without a redeploy, under
You → Server admin → Who can sign up.
Building from the repository instead
If you want to change the code, clone it and let Compose build the image:
git clone https://github.com/Qureshi-DH/hearth.git
cd hearth
cp .env.example .env
The docker-compose.yml in the repository is the same stack with build: in
place of the published image, so docker compose up -d --build compiles the
server from your working tree.
2. Start
docker compose up -d
docker compose logs -f api
The API applies database migrations on boot and logs Hearth is listening.
curl http://localhost:4000/readyz should come back with {"ok":true}.
Interactive API docs are served at /docs (disable with ENABLE_SWAGGER=false).
3. Put TLS in front
If you do not have a domain yet, or you would rather not expose anything to the internet, remote access compares the ways to reach the server from outside and explains why the usual VPN-first advice fits a location app badly.
A production Android build refuses plain HTTP outright and a production iOS build allows it only on private address ranges, so a TLS-terminating proxy is required for anything reachable over the internet, not optional.
Caddy (simplest)
hearth.example.com {
reverse_proxy localhost:4000
}
Caddy gets and renews the certificate on its own and proxies websockets with no extra configuration.
nginx
server {
listen 443 ssl http2;
server_name hearth.example.com;
ssl_certificate /etc/letsencrypt/live/hearth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hearth.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # websocket
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # above the 2 minute websocket ping
}
}
Set TRUST_PROXY=true in .env once a proxy really is in front, or every
sign-in and token refresh in the family counts against one shared rate limit.
Hearth only believes X-Forwarded-For from the machine that connected to it,
and only when that machine is on a private network, so a caller on the internet
cannot pick its own address and walk past the login throttle. That holds while port 4000 is
reachable only through the proxy, which is why the compose files publish it on
127.0.0.1. Publish it on every interface and a caller reaching it directly
arrives through Docker's bridge, from a private address, and is believed. Two
proxies in a row attribute the request to the second one, which shares a
rate-limit bucket rather than losing the limit. Leave it false for a direct
LAN deployment.
Traefik
This one assumes you already run Traefik, with an entrypoint on 443 and a
certificate resolver defined in its static configuration. Substitute your own
names for websecure, letsencrypt and the traefik network. Add the labels
to the api service.
labels:
- "traefik.enable=true"
- "traefik.http.routers.hearth.rule=Host(`hearth.example.com`)"
- "traefik.http.routers.hearth.entrypoints=websecure"
- "traefik.http.routers.hearth.tls.certresolver=letsencrypt"
- "traefik.http.services.hearth.loadbalancer.server.port=4000"
# The container is on two networks. Without this Traefik may pick the one it
# isn't on and answer with a gateway timeout.
- "traefik.docker.network=traefik"
networks:
- default
- traefik
Naming a network drops the implicit default one, which is how api reaches
Postgres, so both have to be listed. Traefik reaches the container over a Docker
network rather than the published port, and that network has to be declared at
the top level of the file too:
networks:
traefik:
external: true
If you add Traefik as a service in this same compose file instead, it is already
on the default network, and you can drop both networks blocks and the
traefik.docker.network label. Websockets work with no extra configuration.
At a path on a domain you already use
Hearth doesn't need a hostname of its own. It can sit at a path, say
https://lab.example.com/hearth, with another app at the root of the domain.
Put the whole address in PUBLIC_URL, path included, and give the phones that
same address.
PUBLIC_URL=https://lab.example.com/hearth
lab.example.com {
redir /hearth /hearth/
handle_path /hearth/* {
reverse_proxy localhost:4000
}
handle {
reverse_proxy localhost:2283 # whatever already lives at the root
}
}
handle_path takes /hearth off before it passes the request on. A proxy that
passes the path on unchanged works too, Caddy's handle /hearth/* for one,
because Hearth takes the path in PUBLIC_URL off any request that still
carries it. The redir line is there because /hearth/* doesn't match a bare
/hearth.
The app, the websocket, invite links and the page they open all follow the
path, and so do the admin portal at https://lab.example.com/hearth/ and the
API docs at /hearth/docs. Port 4000 answers with or without it, so /healthz
still works for a probe that skips the proxy. Pick the path before you hand out
invites, because every phone and every invite link carries it. It can't be one
Hearth already uses, /docs, /join, /healthz, /readyz or /api/v1, and
the server says so at startup if it is.
Sharing a domain means sharing an origin, and a browser draws no line between
two apps on one. A script on the app at the root, its own or one slipped into
it, can call the admin portal's sign-in renewal with your browser's cookie and
act as you in the portal. Put Hearth under a path only beside an app you trust
as much as Hearth itself. A subdomain, hearth.lab.example.com, keeps the two
apart.
4. Connect a phone
Install the Hearth app (see the mobile app page for building it), enter
https://hearth.example.com, or the full address with its path if Hearth sits
at one, sign in as the admin account you configured, create a circle, and
share the invite code or QR with the family.
The invite link and the QR both carry the server's address. Opened on a phone,
they land on a page with an Open in Hearth button that fills in the server and
the code, and with the steps for somebody who doesn't have the app yet. Set
IOS_APP_URL and ANDROID_APP_URL to wherever your family gets the app, such
as a TestFlight invite or an APK you host, and the page links to them.
Otherwise it tells them to ask you.
The admin portal
Open the server's address in a browser, https://hearth.example.com, and sign
in with an administrator account. The portal is where running the server
happens without a phone in your hand:
- a dashboard: fixes and active people over the last two weeks, a week of notifications by what became of them, and each person's phone with its model, app version, when it was last heard from and anything it says is switched off
- a check list of what in the setup will bite later, a plain HTTP address, open sign-up, no push provider, a single administrator, failed notifications
- every account, with its devices, and the controls to sign a lost phone out, set a new password for somebody who forgot theirs, deactivate or reactivate an account, and make or remove an administrator
- the circles on the server, their members and settings
- who can sign up, the server's name and the history ceiling
- the notification queue, a test notification and "send now"
- the audit log
It shows no positions, trails or place coordinates, and nobody else's
notification text: running the server is not being in every circle. Only an
administrator can sign in, with the same rate limits as the app, and the
session's refresh token is a cookie the page's own script cannot read. A portal
session ends twelve hours after signing in, and setting somebody else's
password asks for your own first. Set
ENABLE_ADMIN_PORTAL=false to leave no sign-in page at the address. The admin
screen in the app works either way.
Optional pieces
Push notifications
Read push notifications first. Full push, including
the silent pushes that wake a quiet phone, needs expo and an app you build
yourself with your own Expo, Firebase and Apple accounts. Without those, ntfy is
the self-hosted way to get alerts, and it never wakes the Hearth app itself.
For ntfy, take the docker-compose.ntfy.yml overlay from the repository, set
PUSH_PROVIDER=ntfy and NTFY_BASE_URL=https://ntfy.example.com in .env,
and run:
docker compose -f docker-compose.yml -f docker-compose.ntfy.yml up -d
ntfy.example.com is a second hostname on your proxy, pointed at port 8093.
The overlay starts ntfy with every topic locked, so create Hearth's ntfy account
and set NTFY_TOKEN as the push page describes, or the server can't publish.
Profile pictures
The compose stack runs MinIO for these. It is the only thing Hearth stores as a
file, and the bucket is created on the first upload, so there is nothing to set
up beyond the keys in .env:
S3_ACCESS_KEY_ID=hearth
S3_SECRET_ACCESS_KEY=a-long-random-string
The bucket is never exposed. Images are streamed back through the API, so you secure one hostname rather than two, and object keys are random, which is what makes a picture unguessable rather than any access control.
Any S3 compatible storage works instead. Point S3_ENDPOINT at it, set
S3_REGION, and turn off S3_FORCE_PATH_STYLE if the provider serves buckets
as subdomains. The compose file above falls back to the bundled MinIO only when
S3_ENDPOINT is unset, so a value in .env wins. Leave S3_ENDPOINT empty and
uploads switch off entirely, with avatars falling back to initials on a colour.
The app hides the upload button when the server reports no storage, so this
degrades quietly.
The app resizes to 512 pixels before uploading, which re-encodes the file and so strips the EXIF. That matters more here than in most apps: a phone photo usually records where it was taken.
Whatever you point it at, it needs backing up separately. See below.
Map tiles
By default the app loads a MapLibre style from OpenFreeMap. No key, no tracking, community-run. Liberty is the default in light mode and Dark in dark mode, and people can switch to Bright or Positron, or to Fiord, under You, Appearance, Map style. The choice is per phone.
To be fully independent, host your own tiles with
Martin or TileServer GL
and point MAP_STYLE_URL at your style JSON, plus MAP_STYLE_URL_DARK for the
app's dark theme. Set both, or a phone in dark mode keeps loading OpenFreeMap.
Once either one is set to anything but the default, phones get exactly those two and the OpenFreeMap
alternatives disappear, so nobody's map quietly goes back to a third party.
To offer your own choice of styles, set MAP_STYLES to a JSON array. It
replaces the two URLs above.
MAP_STYLES=[{"id":"streets","name":"Streets","url":"https://maps.example.com/streets.json","scheme":"light"},{"id":"night","name":"Night","url":"https://maps.example.com/night.json","scheme":"dark"}]
| Field | Meaning |
|---|---|
id | What phones remember the choice by. Keep it stable when you rename a style |
name | What the picker shows |
url | The style JSON, over http or https |
scheme | light or dark, the app theme it is drawn under |
The first style of each scheme is the default. A list with no dark style draws dark mode with the light style the phone picked, or the first light one. The server refuses to start on a list that isn't valid JSON, has a style missing a field, or uses one id twice, and names the entry at fault. A phone whose chosen style has gone from the list goes back to the default. The picker only appears when a scheme has more than one style.
The app reads all of this from /api/v1/server-info on launch and whenever the
You screen opens, so nothing has to be rebuilt. The credit line on the map comes
from the attribution in your style's sources. MAP_ATTRIBUTION is published in
server-info too, but the app doesn't show it.
Several API replicas
Redis carries realtime events between replicas. Its overlay lives in the
repository as docker-compose.redis.yml.
docker compose -f docker-compose.yml -f docker-compose.redis.yml up -d --scale api=3
The overlay unpublishes the fixed host port, which only one replica could hold,
so point your reverse proxy at the api service on the compose network rather
than at localhost:4000. The Traefik labels above already do that. Caddy and
nginx have to move into the compose stack to reach it. One replica is plenty for
a household several times over, so most installs never need any of this.
Operations
Backups
There are two stores, and a Postgres dump alone is not a complete backup.
Postgres holds accounts, circles, positions, places, trips and the activity feed:
docker compose exec -T db pg_dump -U hearth hearth | gzip > hearth-$(date +%F).sql.gz
MinIO holds the profile pictures. They are files rather than rows, so pg_dump
never sees them, and a restore from the dump alone leaves every avatar as a
broken link. Copy the volume too:
docker run --rm -v hearth_minio-data:/data -v "$PWD:/backup" alpine \
tar czf /backup/hearth-avatars-$(date +%F).tar.gz -C /data .
hearth_minio-data is the compose project name joined to the volume name, so
it matches the file above. If you run the repository's compose file with
MINIO_DATA_PATH pointed at a host directory, the pictures live there and the
volume is empty, so copy that directory instead. Objects are written once under
a random key and never rewritten, so the copy does not need the stack stopped.
If you pointed S3_ENDPOINT at storage you run elsewhere, back it up there
instead and skip this step.
Restoring means getting the dump in before the API creates the schema, so bring up Postgres on its own first:
docker compose up -d --wait db
gunzip -c hearth-2026-09-07.sql.gz | docker compose exec -T db psql -U hearth -v ON_ERROR_STOP=1 hearth
docker run --rm -v hearth_minio-data:/data -v "$PWD:/backup" alpine \
tar xzf /backup/hearth-avatars-2026-09-07.tar.gz -C /data
docker compose up -d
If the stack has already run once, the schema and the bootstrap administrator are there, and the dump lands on top of them. Stop the API and recreate the database first:
docker compose stop api
docker compose exec -T db psql -U hearth -d postgres -c 'DROP DATABASE hearth' -c 'CREATE DATABASE hearth'
gunzip -c hearth-2026-09-07.sql.gz | docker compose exec -T db psql -U hearth -v ON_ERROR_STOP=1 hearth
docker compose up -d api
Upgrades
docker compose pull
docker compose up -d
If you run an overlay, give both commands the same -f files, or put
COMPOSE_FILE=docker-compose.yml:docker-compose.ntfy.yml in .env so every
command picks them up.
Migrations run automatically on boot. Downgrades aren't supported, so if an
upgrade goes badly, restore a backup. Pin a tag instead of latest if you would
rather choose your moment.
Working from a clone, where Compose builds the image rather than pulling it,
that becomes git pull && docker compose up -d --build.
Data retention
Breadcrumb history is pruned by a background job, which runs every
JOB_INTERVAL_SECONDS (60 by default). Each circle sets its own window, 30 days
out of the box, and a user's breadcrumbs live as long as the most generous
circle they belong to asks for. Set a circle's retention to 0 and only the live
position is kept. Trips survive pruning as aggregates: distance, duration,
endpoints.
A server-wide ceiling applies on top of whatever the circles ask for.
MAX_HISTORY_RETENTION_DAYS (90 by default) sets the ceiling the server boots
with, and an admin can change it from the app under You → Server admin →
History retention. The value stored there overrides the environment variable
from that point on, in both directions: the app can raise the ceiling as well as
lower it, and the next sweep uses the new number without a redeploy. Clear the
field in the app and the environment value is back in charge.
The app reads the ceiling from GET /server-info and offers nothing longer in a
circle's settings, with a line saying why. A circle that asks for more, from an
older app or a script, is stored at the ceiling rather than refused. A circle
set longer before you lowered the ceiling keeps its number but is pruned at the
ceiling, and the app shows it at the ceiling.
One wrinkle worth knowing before you touch anything on that screen: saving any
server setting writes the whole set, so renaming the server also stores whatever
ceiling was in force at that moment. From then on, editing
MAX_HISTORY_RETENTION_DAYS in .env changes nothing until you clear the field
in the app. REGISTRATION_MODE and SERVER_NAME are stored the same way and
have no field to clear, so after the first save they change from the app only.
Health
GET /healthzsays the process is upGET /readyzsays the database is reachable, and it's the one to point your proxy and uptime checks atGET /api/v1/admin/stats(admin token) returns users, points, queue depth and DB size, and the admin portal's overview shows the same, with two weeks of activity fromGET /api/v1/admin/overview
Logs
JSON lines on stdout. LOG_LEVEL=debug when something is wrong. Authorization
headers and passwords are redacted before they reach the log.
Security notes
The server never stores plaintext refresh tokens. They're SHA-256 at rest and rotate on every use, and a replayed token is rejected.
Passwords are hashed with scrypt (N=2¹⁵) using Node's built-in implementation. That means no native build step, which is why the image builds on ARM boards without a compiler.
Rate limits are per account, falling back to per IP, so one chatty phone on a home NAT doesn't throttle the whole household.
Every table but the audit log and the server settings cascades from users and
circles. Deleting an account deletes every breadcrumb, trip, alert and
notification tied to it. Places it created in a shared circle stay, for the
others who use them. Two things outlive it. Audit log rows lose the link to
the account but keep what they recorded, which for a reused refresh token is the
device id and IP address. And the profile picture stays in the bucket, which the
cascade cannot reach. Nothing links to it any more and its key is random, but
/media/ serves it without a token, so anyone who already has its link can
still load it until the operator removes the object.
CORS_ORIGINS is empty by default, which grants no cross-origin access at all.
Native apps don't send an Origin header and don't need one. Add your web
client's origin there the moment you have a web client. Authentication is a
bearer token and never a cookie, so credentialed cross-origin requests never
come into it.
Running without Docker
pnpm install
cp .env.example server/.env # or export the variables
pnpm --filter @hearth/server build
NODE_ENV=production DATABASE_URL=postgres://... JWT_SECRET=... \
pnpm --filter @hearth/server start
Fill in ADMIN_EMAIL and ADMIN_PASSWORD in server/.env before that first
start. They create the account the server boots with, which is the only way in,
and in production the server refuses to start without them rather than leaving
you one nobody can sign into.
You'll need Node 22.12+ and Postgres 14+. The workspace's dev tooling needs
that Node, and pnpm install refuses an older one. The image runs Node 24.
Object storage is optional: leave S3_ENDPOINT unset and profile pictures are
simply switched off.