HomeKeep ships one container with three supported deployment modes. Pick one and stick with it — they all use the same image (ghcr.io/OWNER/homekeep:latest) and the same ./data volume, so you can switch later by changing compose files without touching data.
| Mode | HTTPS | PWA install | Setup time | Best for |
|---|---|---|---|---|
| LAN only (default) | No | No | 2 min | Single-home, inside-house usage |
| Caddy (public domain) | Yes, auto | Yes | 15 min | Public-facing self-host with a domain |
| Tailscale (tailnet) | Yes, auto | Yes | 10 min | Private access from anywhere without a public IP |
The file
docker/docker-compose.ymlis the baseline shared by every mode. The Caddy and Tailscale modes layer an overlay file on top via Compose's-fmerge. The baseline file is never modified by the overlays.
Start:
cp .env.example .env
docker compose -f docker/docker-compose.yml up -dHomeKeep is then reachable at http://HOST_IP:3000 on your local network (change HOST_PORT in .env to map a different host port, e.g. HOST_PORT=80).
The
./datafolder must be writable by uid 1000 (the container repairs ownership at boot when it can; if you seepermission deniedindocker logs, runsudo chown -R 1000:1000 ./data).
curl -s http://localhost:3000/api/health
# { "status": "ok", "nextjs": "ok", "pocketbase": "ok", "pbCode": 200 }- No HTTPS — the in-container Caddy serves plain HTTP on port 3000. Browsers will correctly refuse to install HomeKeep as a PWA or register a service worker.
- No offline support — the service worker only registers on HTTPS or
http://localhostcontexts. - The HTTP-only banner (
components/insecure-context-banner.tsx) explains this to users automatically and links back to this guide.
git pull && docker compose -f docker/docker-compose.yml pull && docker compose -f docker/docker-compose.yml up -dThis mode terminates TLS at an external Caddy sidecar in front of the homekeep container. Caddy manages the cert lifecycle automatically via Let's Encrypt.
- A domain you control, e.g.
homekeep.example.com. - DNS A record pointing the domain at your VPS public IP.
- Ports 80 + 443 open on the VPS firewall (ACME HTTP-01 uses 80; HTTPS is on 443; UDP 443 is used by HTTP/3).
- A
.envfile withDOMAIN=homekeep.example.com. Optionally setCADDY_EMAIL=you@example.comfor Let's Encrypt renewal notices (defaults toadmin@${DOMAIN}if omitted).
cp .env.example .env
# edit .env and set DOMAIN=your-domain.example.com (and optionally CADDY_EMAIL)
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.caddy.yml \
up -dThe first request to https://DOMAIN/ triggers an ACME HTTP-01 challenge; Caddy obtains a cert within about 15 seconds and thereafter serves HTTPS with automatic renewal. The cert and ACME account key persist in the caddy_data named volume.
Do NOT delete the
caddy_datavolume. Let's Encrypt rate-limits certificate issuance to 5 per week per domain. Losing the volume will force Caddy to re-request a cert on next boot and can lock you out for a week if you cycle deployments too fast. If you suspect rate-limit trouble, use the staging directory temporarily — see Troubleshooting below.
curl -sS https://$DOMAIN/api/health
# { "status": "ok", "nextjs": "ok", "pocketbase": "ok", "pbCode": 200 }On success you can now install HomeKeep as a PWA — see docs/pwa-install.md.
git pull && \
docker compose -f docker/docker-compose.yml -f docker/docker-compose.caddy.yml pull && \
docker compose -f docker/docker-compose.yml -f docker/docker-compose.caddy.yml up -ddocker/Caddyfile.prodis a separate Caddy config fromdocker/Caddyfile(which stays inside the homekeep container for/apivs/_/routing). The prod file uses Caddy's automatic HTTPS — it does NOT setauto_https off.- The overlay hides the homekeep service's host port via Compose's
!reset []directive (requires Compose v2.24+). Only Caddy binds host ports 80 + 443 + 443/udp. The two containers communicate on the internal compose network athomekeep:3000. DOMAINis interpolated into Caddyfile.prod via Caddy's env-substitution ({$DOMAIN}). If unset, the overlay fails fast with a clear error rather than silently serving a self-signed localhost cert.
This mode joins the homekeep container to your tailnet via a sidecar running the official Tailscale image. No public IP or open ports are needed — only tailscale peers can reach the service.
- A Tailscale account (free for personal use up to 100 devices; sign up at https://tailscale.com ).
- A reusable auth-key from https://login.tailscale.com/admin/settings/keys — tick Reusable when generating (Ephemeral is optional, it auto-removes the node when the container exits).
- MagicDNS + HTTPS enabled on the tailnet at https://login.tailscale.com/admin/dns — this is what gives the container a
https://homekeep.<tailnet>.ts.netURL.
cp .env.example .env
# edit .env and set TS_AUTHKEY=tskey-auth-...
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.tailscale.yml \
up -dOnce the sidecar authenticates (about 5 seconds), HomeKeep is reachable at https://homekeep.<tailnet>.ts.net on any tailscale-connected device. The TLS cert is provisioned by Tailscale directly; no further action is needed.
# from any tailnet peer:
curl -sS https://homekeep.<your-tailnet>.ts.net/api/healthYou can expose the tailnet HomeKeep publicly over HTTPS by dropping a Tailscale serve.json configuration into the tailscale_state volume and setting TS_SERVE_CONFIG=/config/serve.json. This is an advanced option; see https://tailscale.com/kb/1223/funnel for configuration details.
docker/docker-compose.tailscale.ymlshares the sidecar's network namespace with the homekeep service vianetwork_mode: "service:tailscale". The tailscale IP fronts the Next.js server directly; no host port is published.- Kernel networking is enabled (
TS_USERSPACE=false+cap_add: [net_admin, sys_module]+/dev/net/tunmount). If your host lacks/dev/net/tun— e.g. certain locked-down container hosts — setTS_USERSPACE=trueand drop the cap_add/volume entries. Kernel mode is faster. TS_AUTHKEYuses Compose's:?fail-fast directive, matching theDOMAINpattern in the Caddy overlay.
git pull && \
docker compose -f docker/docker-compose.yml -f docker/docker-compose.tailscale.yml pull && \
docker compose -f docker/docker-compose.yml -f docker/docker-compose.tailscale.yml up -dA separate overlay, docker/docker-compose.demo.yml, stands up a public-facing ephemeral-data demo of HomeKeep. Every visitor gets their own throwaway user + home + 15 seed tasks on first load; data lives in tmpfs and is wiped after 2 hours of idle time OR 24 hours absolute, whichever comes first.
This is NOT the mode to run your personal household on. For personal use, run the LAN or Tailscale overlays above.
- Swaps the baseline
./data:/app/databind-mount for a tmpfs volume (256 MB cap). PB state never touches disk. - Sets
DEMO_MODE=true, which activates:lib/demo-session.ts— per-visitor ephemeral home seeding at/api/demo/session.components/demo-banner.tsx— the amber warning banner in the app layout.pocketbase/pb_hooks/demo_cleanup.pb.js— 15-minute cron that sweeps idle / absolute-TTL-expired demo users.
- Sets
DISABLE_SCHEDULER=true+HK_BUILD_STEALTH=true+NTFY_URL=""so the demo emits no outbound notifications and redacts build-id metadata. - Pins the image via
GHCR_OWNER+TAGenv vars (the-kizz/latestby default;edgeduring active dev).
- DNS A record for
homekeep.demo.<your-domain>→ the demo VPS's public IP. - Ports 80 + 443 open on the VPS firewall (ACME HTTP-01 on 80, HTTPS on 443, HTTP/3 on UDP 443).
docker/.env.demopopulated locally (the checked-in template is the starting point — you MUST rotatePB_ADMIN_PASSWORDviaopenssl rand -hex 24beforecompose upor the admin client fails fast).- Caddyfile swap: by default the
docker-compose.caddy.ymloverlay mountsCaddyfile.prod. For the demo host, swap toCaddyfile.demovia an ad-hoc compose override (see below) or run a single demo instance whereDOMAIN=homekeep.demo.<your-domain>is set in.env.demo— Caddyfile.prod's{$DOMAIN}substitution works just as well for the demo host (the hostnames in the block are env-driven).
# On the VPS, from the repo root:
cp docker/.env.demo docker/.env.demo.local # keep the template intact
# edit docker/.env.demo.local and set PB_ADMIN_PASSWORD=$(openssl rand -hex 24)
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.caddy.yml \
-f docker/docker-compose.demo.yml \
--env-file docker/.env.demo.local \
up -dThe first request to https://homekeep.demo.<your-domain>/api/demo/session triggers:
- ACME HTTP-01 challenge on port 80 (Caddy auto-fetches a Let's Encrypt cert — ~15s on first run, persisted in the
caddy_datavolume). lib/demo-session.tsspawns a throwaway user + "Demo House" + Kitchen + Outdoor + Whole Home areas + 15 seed tasks.- 303 redirect to
/h/<homeId>with the user already authenticated.
# from anywhere — TLS should auto-negotiate within 15s of first hit:
curl -sS https://homekeep.demo.<your-domain>/api/health
# first-visit flow returns a 303 to /h/<id>:
curl -sSI https://homekeep.demo.<your-domain>/api/demo/session- tmpfs peak: ~50 MB for ~20 concurrent demo sessions (well under the 256 MB cap).
- Cleanup cron runs every 15 min; the log line
[demo-cleanup] swept N demo users (M homes)indicates it's working. - Admin UI (
/_/*,/api/_superusers/*) is blocked at the edge with 404 (no escape hatch on the demo Caddyfile).
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.caddy.yml \
-f docker/docker-compose.demo.yml \
down
# Everything in tmpfs is already gone. The caddy_data volume (Let's
# Encrypt cert + ACME key) stays — DO NOT delete it or you'll hit the
# 5/wk LE rate limit on re-issuance.The demo VPS is a public-demo host only. The personal instance lives on the home-lab server behind Tailscale. Running both on the same VPS would share the caddy_data volume between hosts and accidentally expose the personal instance's admin UI if the Caddy config drifted.
Running HomeKeep on a public domain is NOT a one-command experience. The Caddy overlay above gives you TLS and the basic reverse-proxy wiring, but every production instance also needs strong secrets, a tight firewall, rate-limit posture, and a rotation plan.
See docs/deployment-hardening.md for the
full 15-item operator checklist. Work through it top-to-bottom before
your first public cut-over, and revisit it quarterly alongside the
90-day secret rotation.
For vulnerability reporting and the project's threat model, see
SECURITY.md.
HomeKeep ships multi-arch container images to GitHub Container Registry (GHCR) via .github/workflows/release.yml. The workflow is triggered by a git tag matching v* and publishes:
ghcr.io/OWNER/homekeep:v1.0.0-rc1(exact semver)ghcr.io/OWNER/homekeep:1.0(major.minor)ghcr.io/OWNER/homekeep:latest(only when the tag is on the default branch)
Both linux/amd64 and linux/arm64 architectures are built in the same manifest list.
git tag v1.0.0-rc1
git push origin v1.0.0-rc1
# Watch: https://github.com/OWNER/homekeep/actions/workflows/release.ymlAfter the workflow completes, verify both architectures landed in the manifest:
sh scripts/check-multiarch.sh ghcr.io/OWNER/homekeep:v1.0.0-rc1
# should output linux/amd64 and linux/arm64 presentThe same scripts/check-multiarch.sh is what the release workflow itself runs as its last step — operator-side verification is just a re-run.
- Workflow permissions.
Settings / Actions / General / Workflow permissions→ Read and write. Required so the release job can push to GHCR using the defaultGITHUB_TOKEN. - Branch protection on
main. Require thelint-test-buildstatus check from.github/workflows/ci.ymlto pass before merging. Prevents merging a broken main that would then get tagged. - GHCR package visibility. After the first
v*tag push lands an image, flip the package to Public sodocker pullworks without credentials: GitHub profile or org / Packages / homekeep / Package settings / Change visibility.
Smoke-check commands:
# release.yml still parses as YAML
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))"
# trigger is still v* tag push
grep -E "tags:\s*\[\s*'v\*'\s*\]" .github/workflows/release.yml
# actions are still pinned to major versions; no floating refs
grep -E "@v[0-9]" .github/workflows/release.yml
grep -E "@(master|latest)" .github/workflows/release.yml && echo FAIL || echo OK
# helper scripts still executable
test -x scripts/check-multiarch.sh
test -x scripts/check-image-size.sh- Caddy returns
429 Too Many Requestsfrom Let's Encrypt. Thecaddy_datavolume was deleted or cycled too fast and the rate limit (5 certs/week/domain) kicked in. Either wait a week, or temporarily switch Caddy to the staging CA by addingacme_ca https://acme-staging-v02.api.letsencrypt.org/directoryto the global block indocker/Caddyfile.prod. The staging cert is untrusted but unblocks iteration. - Tailscale sidecar exits immediately. Almost always a rejected
TS_AUTHKEY. Re-generate a fresh reusable key at https://login.tailscale.com/admin/settings/keys and update.env. If the key is valid but the sidecar still fails, check host-side/dev/net/tunpermissions or fall back toTS_USERSPACE=true. - PWA install prompt never appears. You are on HTTP. The
InsecureContextBannercomponent will explain this to the user. Switch to the Caddy or Tailscale overlay. - Compose
!reset []unrecognised. Your Docker Compose is older than v2.24. Update Compose, or replaceports: !reset []withports: []in the overlay (older Compose merges list-merge the same way). docker compose config --quietfails withDOMAIN is unset. The overlay intentionally fails fast. Eitherexport DOMAIN=...in the shell before runningconfig, or putDOMAIN=...in.envbefore runningup -d.