Guide βΊ Self-hosting
Install and self-host
Docker Compose in five minutes, the three environment variables, why a reverse proxy with authentication is not optional, HTTPS for the camera and microphone, updating, and a bare-metal dev run.
Handlr is one container, one volume, no database server. It is meant to live on the box in your closet, on your LAN, behind your own front door.
Running it for other people?
That is hosted mode: one instance, many profiles behind a login, each in its own database. Everything on this page still applies; that page covers the rest.
Requirements
- Docker with Compose (any recent version), on any Linux host β a Raspberry Pi is plenty.
- A reverse proxy that can add authentication and HTTPS (Caddy, Traefik, nginx, Nginx Proxy Managerβ¦), or a private network (Tailscale, NetBird, WireGuard) that already does.
- About 200 MB for the image; storage for your photos and voice memos.
The easy path: the setup wizard
If you would rather not touch a terminal, the Handlr Setup wizard does everything on this
page for you β checks Docker, picks a folder, mints the API token, writes the compose file and
starts the app. The port it publishes is bound to 127.0.0.1: Handlr opens on that computer
only. Opening it to your network is a separate box on the Options page β Let other devices
on my network open Handlr β off unless you tick it, and it says in plain words what ticking it
means: Handlr has no login, so anyone on your network could open it and download everything in
it. A login in front of Handlr (a reverse proxy) does not change that β devices still reach the
port directly β so leave it off when the proxy runs on the same computer: it reaches Handlr
on 127.0.0.1. Using Tailscale or NetBird? Leave it off too, and once Handlr is installed set
HANDLR_BIND in the install folder's .env to the computer's tailnet address (100.x.y.z), then
docker compose up -d there: only devices on the tailnet can reach it. After that, open Handlr at
http://<tailnet address>:<port> on this computer too β http://localhost:<port> stops answering
once it is bound to the tailnet address. Ticked, Handlr listens
on 0.0.0.0 β every interface, including any Wi-Fi the computer joins later β so tick it only on
a computer that never joins a network you don't trust (every device on every network it joins
can open Handlr), or when the proxy runs on another machine and you firewall the port to that
machine alone β and on Linux, Docker's published ports bypass host firewalls such as UFW
(below). The choice is the HANDLR_BIND line in the .env it writes
(127.0.0.1 or 0.0.0.0, or the address you put there; empty or missing means 127.0.0.1),
and the last page shows the address for the choice you made. Running the wizard again rewrites
.env, so a HANDLR_BIND you set by hand goes back to 127.0.0.1 β set it again.
Download it from https://boyslop.dev (macOS, Intel and Apple Silicon), or run
installer/setup.sh from the source tree on any machine with Python 3 β no build needed.
Five-minute install
git clone <the repository> handlr && cd handlr
cp .env.example .env
# set HANDLR_API_TOKEN if you want the API; leave it empty to keep the API off
docker compose build --pull
docker compose up -d
curl -s http://127.0.0.1:8770/healthz
/healthz answers with the version, the session state and the loaded modules. The image is
built locally from the Dockerfile; there is no registry pull yet
(roadmap β releases).
The shipped docker-compose.yml publishes port 8770 on the host, mounts a named volume at
/data, runs read-only with a 512 MB limit, and sets TZ/HANDLR_TZ. Change the host port or
the timezone to taste.
The environment
Renamed in 0.32.0
Handlr was called Latch until 0.32.0. Every HANDLR_* setting below is also read under its
old LATCH_* name until November 2027, and the app logs each old name it finds, once, at
start. Nothing on disk changed: the database is still latch.db, exports still start with
latch-, the pup file is still a latch.pup document, and a playset's latch = ">= β¦" line
still names the minimum version β those are file formats, not the name on the door.
| Variable | What | Default |
|---|---|---|
HANDLR_API_TOKEN |
bearer token for /api/v1. Empty = API disabled (503), on purpose. Generate with python3 -c 'import secrets;print(secrets.token_urlsafe(32))' and keep it in your password manager. |
(empty) |
HANDLR_TZ |
your local timezone (IANA name) | America/Detroit |
HANDLR_APP_NAME |
display name | Handlr |
HANDLR_ALLOWED_HOSTS |
the names this box answers to, comma-separated; *.home.example matches any subdomain. IP addresses and localhost always work. See the names it answers to. |
(empty: only IP addresses and localhost until you name it) |
More in Customise β instance settings.
Authentication is yours
Handlr has no login of its own
Anyone who can reach the port can play your game and read your journal. The port must be reachable only from your reverse proxy (or your private network), and the proxy must ask for a login. This is the one piece of setup that is not optional.
Two patterns that work:
A. Reverse proxy with forward-auth. Caddy + tinyauth/Authelia/Authentik, Traefik with a
forward-auth middleware, nginx with auth_request. Example for Caddy with tinyauth:
handlr.home.example {
tls internal
forward_auth tinyauth:3000 {
uri /api/auth/caddy
copy_headers Remote-User
}
reverse_proxy latch-host:8770
}
Then make sure 8770 is not reachable from anything but the proxy β a firewall rule on
the host, or bind the port to the proxy's address only. Note that Docker's published ports
bypass host firewalls such as UFW; use DOCKER-USER rules or bind to 127.0.0.1 if the
proxy is on the same host.
B. Private network only. If the host is reachable only over Tailscale/NetBird/WireGuard
and every device on that network is yours, the network is the login. Publish the port on the
host's address on that network, not on every interface: HANDLR_BIND=100.x.y.z with the
wizard's compose, or "100.x.y.z:8770:8000" on your own ports line. 0.0.0.0 is every
interface β the LAN and any Wi-Fi the machine joins included β so it is not "the private
network only". The address must exist when Docker starts Handlr: if Handlr does not come back
after a reboot, the tailnet was not up yet β docker compose up -d again. Open it at
http://100.x.y.z:8770 on the host itself too: bound there, it no longer answers on
localhost. Still terminate HTTPS
somewhere, for the next section.
HTTPS is required
Not for privacy β the traffic never leaves your LAN β but because browsers refuse three
things on an insecure origin: the microphone (spoken reflections), the camera on some
phones, and the share sheet. All three work at https://handlr.your.lan through your proxy
and are structurally absent at http://host:8770. A proxy-issued internal certificate (Caddy's
tls internal, or your own CA) is fine; install its root on your phone once.
The names it answers to
Handlr answers only to names it has been told about. A web page on someone else's name can
point that name at your box (DNS rebinding) and then read whatever your browser can reach β
including /export/bundle.zip, which is your whole database. The only thing different about
that request is the name in it, so any name that is not yours gets
"This box does not answer to that name" and nothing else.
- IP addresses and
localhostalways work β health checks,curlon the host, the installer, and the way in when nothing else is set up yet. - Set
HANDLR_ALLOWED_HOSTSto the name(s) you use, e.g.handlr.home.exampleorhandlr.home.example,*.tailnet.example. This is the recommended setting. - Or leave it empty and name the box yourself: open it by its IP address (or
localhost), go to Settings β System β answers to, type the name you use and press Answer to this name. Until you do, only IP addresses andlocalhostreach it. To move to a new name, give the new one (it replaces the old), or press Forget this name β from the IP address, since the old name stops working at once. - A name is never learnt from a request. Over plain
http://, nothing in a request tells an address you typed apart from a rebinding page's own script, so the box never guesses: the name comes from you, on a page a stranger's site can neither load nor post to. - The setup wizard writes
HANDLR_ALLOWED_HOSTS=localhostinto.env; add any other name you open it by there. - Entries are bare names:
handlr.home.example,*.home.example, optionally with a port. A URL (https://β¦), a bare*or a trailing slash never matches; Settings β System lists such entries as ignored.
Handlr also refuses a form sent from another site (a cross-site POST), which is what stops a
page you visit from changing your settings behind your back. Scripts and Shortcuts are not
affected: they send neither of the headers a browser does, and /api/v1 uses its token.
Behind a reverse proxy
Pass the name through
The proxy must hand Handlr the name your browser used: pass the Host header through
(nginx: proxy_set_header Host $host; β Caddy and Traefik do this by default), or set
X-Forwarded-Host to it. nginx's default rewrites Host to the upstream address
(127.0.0.1:8770, or handlr:8000 for proxy_pass http://handlr:8000;), and then Handlr
cannot tell your name from a stranger's: if your proxy also answers other names (it
does when this is its only or default site), a rebinding page on such a name can read
your box through it. Listing the name in HANDLR_ALLOWED_HOSTS does not fix that β the
name never reaches the box to be checked.
- With
Hostpassed through, put the public name inHANDLR_ALLOWED_HOSTS(or give it on Settings) and everything works. - With
X-Forwarded-Hostinstead, every value in it is checked likeHost, and the rewrittenHosthas to pass too: list the upstream name as well (HANDLR_ALLOWED_HOSTS=handlr.home.example,handlr) unless it is an IP address. - Without either, a form from your own page still works when its origin is an exact name in
HANDLR_ALLOWED_HOSTS(and Handlr logs a warning that the proxy is rewritingHost), but a browser that sendsOrigin: nullis refused, and the rebinding read above stays open.
Updating
Updating an install made before 0.32.0 (when the app was called Latch)
Your data lives in a Docker volume named after the folder the compose file sat in β usually
latch_latch_data (see it with docker volume ls). The new compose asks for a volume called
handlr_data; point it at yours instead of creating an empty one by adding one line to .env
before docker compose up -d:
HANDLR_DATA_VOLUME=latch_latch_data
Never rename or copy the volume. Your old LATCH_* lines in .env keep working until November
2027; swap them for HANDLR_* when convenient. If you ran the voice sidecar, its address under
Settings β Trance changes from http://latch-voice:8000 to http://handlr-voice:8000.
git pull
docker compose build --pull
docker compose up -d
curl -s http://127.0.0.1:8770/healthz # the version must read back as the one you built
docker compose pull does nothing here β the image is local. Read the version off
/healthz rather than trusting the exit code. The changelog is CHANGELOG.md; schema
migrations run on start and are recorded in the database.
If you keep a rollback: docker tag aurora/handlr:local aurora/handlr:rollback-$(date +%F)
before the build.
Backups
One volume: the database (WAL mode) and media/. See
Export and backup for a WAL-safe copy and the watermark
that proves it.
Running on a workstation (development)
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -r requirements.txt pytest httpx
./run-dev.sh # http://127.0.0.1:8770, data in ./.data, API token "dev-token"
.venv/bin/pytest -q # the test suite, including the privacy and settings-split guards
.data/ is throwaway and gitignored. The dev server and a container share nothing.
A worked example
The reference deployment runs on a Proxmox VM behind Caddy (tls internal, tinyauth β
passkeys), with the raw port firewalled to the proxy host and to the private mesh, a
keyword health monitor on /healthz, a nightly WAL-safe database dump, and the whole VM in
two backup layers. None of that is required; all of it is the shape the
privacy page describes.
This page is docs/install.md in the repo. Spotted a mistake? Tell us how to report it β