No description
  • Python 53.9%
  • TypeScript 41.5%
  • CSS 4.1%
  • Dockerfile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Brian 5bf6cd2f03
All checks were successful
Build and Deploy / build-frontend (push) Successful in 18s
Build and Deploy / build-backup (push) Successful in 26s
Build and Deploy / build-backend (push) Successful in 50s
Build and Deploy / deploy (push) Successful in 26s
feat: add Borg backup and restore via a backup agent sidecar
A new `backup` container dumps Postgres (via docker exec in the db
container), pauses the backend while snapshotting the database and uploads
into an encrypted remote Borg repository, and restarts it. Restores load
into a scratch database while the app is still up and only swap it in with
the backend stopped. Scheduling (cron in the app timezone), retention,
SSH key management, archive listing and restore are admin-managed in
Settings -> Backups; the backend proxies to the agent with a shared token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4iwCtrKkciCSPHg61xZjc
2026-09-14 15:12:35 -06:00
.forgejo/workflows feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
backend feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
backup feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
frontend feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
.env.example fix: compute "today" from the configured app timezone, not raw UTC 2026-07-19 10:46:39 -06:00
.gitignore Add editable links/trackers, image icons, tracker alerts, settings 2026-06-09 19:37:11 -06:00
CLAUDE.md feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
docker-compose.override.yml feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
docker-compose.yml feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00
README.md feat: add Borg backup and restore via a backup agent sidecar 2026-09-14 15:12:35 -06:00

🏠 Home Manager

A self-hosted app for keeping on top of your house:

  • Trackers — recurring upkeep with a "next due" date (water the plants every 7 days, service the AC, replace filters). One tap to mark done, editable, with a "recent activity" feed. Optional alerts when something comes due, with a configurable lead time and time of day to fire. Each tracker is either household (everyone can see and complete it) or private (only the creator); only the creator can delete it.
  • History — a log of everything done to the house, big (roof replaced) and small, with cost/vendor/notes. Tap any entry to see its full details and edit it (or attach/remove receipts). (Tracker activity is kept separate, on the Trackers page.)
  • Goals — one-off and repeating goals (daily / weekly / monthly). Daily goals show a week grid to tick off each day; weekly/monthly goals show done/not for the current period. Goals are owned by their creator and can be shared with other members for accountability (a sharee can see the goal and check it off; only the owner can edit, delete, or change sharing).
  • To Do — simple checklists. Each list has a name and holds free-text items you can add, edit, check off, and delete; completed items drop to a struck-through "Completed" area at the bottom of the list. Lists are owned by their creator and can be private or shared — a sharee can always see the list and check items off (we record who completed each item and when), and can be granted edit rights to add/edit/remove items too. Only the owner can rename, delete, or change sharing.
  • Favorites — organise your favourite things into categories (Restaurants, Films, Books, …) and add items with a name, photo, a 1-10 rating, and notes. Each category and each individual item is owned by its creator and can be shared with other members to view: share a whole category (they see every item) or just a single item (they see only that one). Only the owner can edit, delete, or change sharing.
  • Dashboard — quick links to your other household apps (Plex, Jellyfin, Audiobookshelf, Home Assistant, …) with custom icon images (upload or URL), editable, plus an at-a-glance "needs attention" view.
  • Alerts — per-tracker reminders delivered to each user's own channels: email, SMS (via a carrier email-to-text gateway), and mobile push (ntfy). Configure your contacts and channels under Settings. Alert times are interpreted in a household timezone that admins set under Settings.

Built to work well on mobile and desktop. Logins are OIDC via Authentik, and household members are auto-provisioned on first sign-in.

Stack

Layer Tech
Backend FastAPI · SQLAlchemy 2 (async) · Postgres · PyJWT
Frontend React + Vite + TypeScript · TanStack Query · react-oidc-context
Auth Authentik OIDC (Bearer JWT, or forward-auth headers)
Deploy Docker Compose (db + backend + frontend/nginx + Borg backup agent)

Quick start (Docker)

cp .env.example .env
# edit .env: set POSTGRES_PASSWORD and the Authentik OIDC values
docker compose up --build

App is served at http://localhost:8080. The frontend's nginx proxies /api to the backend, so there's a single origin to expose.

Try it without Authentik

To kick the tyres before wiring up OIDC, run in dev-auth mode — every request is treated as a local admin user:

HM_DEV_AUTH=true docker compose up --build

⚠️ Never enable HM_DEV_AUTH in production — it disables authentication.


Local development (no Docker)

Backend (Python 3.12+):

cd backend
python -m venv .venv && source .venv/bin/activate   # repo already ships a .venv
pip install -e ".[dev]"
HM_DEV_AUTH=true HM_DATABASE_URL="sqlite+aiosqlite:///./dev.db" \
  uvicorn app.main:app --reload

(For Postgres locally, point HM_DATABASE_URL at a running instance and drop the SQLite URL.)

Frontend:

cd frontend
npm install
npm run dev   # http://localhost:5173, proxies /api -> http://localhost:8000

Tests / typecheck:

cd backend && pytest          # API smoke tests on in-memory SQLite
cd frontend && npm run build  # tsc typecheck + production build

CI/CD (Forgejo Actions → Portainer)

.forgejo/workflows/build-deploy.yml runs on every push to main (and via manual dispatch). It:

  1. builds and pushes homemanagement-backend, homemanagement-frontend and homemanagement-backup images to git.thenymans.com/brian, tagged both :latest and :<commit-sha>;
  2. redeploys the Portainer stack, pinning IMAGE_TAG to that commit's SHA and pulling the new images.

The repo's docker-compose.yml is the stack Portainer runs — it references the registry images. Local development builds from source instead via docker-compose.override.yml (merged automatically by docker compose up --build); that override is never sent to Portainer.

Required Forgejo repo secrets:

Secret Purpose
REGISTRY_USER Registry login user for docker push
REGISTRY_PASSWORD Registry login password/token
PORTAINER_TOKEN Portainer API key (X-API-Key)
PORTAINER_STACK_ID ID of the existing Portainer stack to update
PORTAINER_ENDPOINT_ID Portainer environment/endpoint ID

Postgres and OIDC values (POSTGRES_PASSWORD, HM_OIDC_ISSUER, …) live as env vars on the Portainer stack itself — the deploy job preserves them and only overrides IMAGE_TAG, so secrets never need mirroring into CI. The runner must use the docker label and be able to reach the registry and Portainer.

If your repo owner isn't brian, update NAMESPACE in the workflow and IMAGE_NAMESPACE in .env / the Portainer stack env.


Configuring Authentik

  1. In Authentik, create an OAuth2/OpenID Provider:

    • Client type: Public (the SPA uses Authorization Code + PKCE).
    • Redirect URI: https://homemanager.example.com/ (your app's origin, with trailing slash).
    • Note the Client ID and the provider's Issuer URL. The last path segment is your Authentik application slug and must match it exactly, e.g. https://auth.thenymans.com/application/o/home-management/. Confirm by opening <issuer>/.well-known/openid-configuration — it must return JSON.
  2. Create an Application bound to that provider.

  3. (Optional) Create a group — e.g. home-manager-admins — and add the members who should be able to manage dashboard links. Make sure your provider emits a groups scope/claim.

  4. Fill in .env:

    HM_OIDC_ISSUER=https://auth.example.com/application/o/home-manager/
    HM_OIDC_AUDIENCE=<the Client ID>
    HM_ADMIN_GROUP=home-manager-admins
    HM_CORS_ORIGINS=https://homemanager.example.com
    

The SPA discovers these at runtime from /api/config, runs the OIDC code flow, and sends the access token as a Bearer token. The backend validates the token signature against Authentik's JWKS and creates the User row on first sight. Admins are anyone whose token carries HM_ADMIN_GROUP in its groups claim.

Alternative: forward-auth

If you instead front the backend with an Authentik proxy/forward-auth outpost, set HM_TRUST_FORWARD_AUTH=true. The backend will then trust the X-authentik-* headers the outpost injects. Only do this when the outpost is the only network path to the backend.


Notifications (tracker alerts)

Alerts are opt-in per user and per tracker:

  • Per tracker (Trackers page → edit): toggle Send alerts when due and set a lead time (e.g. remind 3 days before due). Requires a repeat interval.
  • Per user (Settings page): turn on Email / SMS / Push and provide each channel's contact detail. A Send test button verifies delivery.

A background scheduler notifies every user whose channels are configured when a tracker reaches due date − lead days. Each tracker fires once per due cycle; marking it done re-arms it.

Server settings are admin-managed in the app, not via env. An admin opens Settings → Server notifications to turn the scheduler on/off, set the scan interval, and configure SMTP and ntfy (secrets are write-only — they're never returned to the browser, and a blank field leaves the stored value unchanged). The HM_ALERTS_ENABLED / HM_SMTP_* / HM_NTFY_* env vars are only seed defaults applied on first startup; after that the database is the source of truth. Changes in the admin UI take effect without a restart.

Admins: the first user to sign in is automatically made an admin (so a fresh install always has someone who can manage settings). Beyond that, membership of HM_ADMIN_GROUP grants admin. Admin is sticky — it's never revoked on a later login, which also protects against an OIDC token that omits the groups claim.

Channels:

  • Email — standard SMTP (HM_SMTP_*).
  • SMS — delivered as an email to a carrier email-to-text gateway, so it reuses SMTP — no Twilio/etc. Each user enters their gateway address (e.g. 5551234567@vtext.com for Verizon, @txt.att.net for AT&T).
  • Push — ntfy. The admin sets the ntfy server; each user picks a personal topic and subscribes to it in the ntfy mobile app. Choose an unguessable topic — anyone who knows it can read the alerts. Published via ntfy's JSON API (UTF-8, emoji-safe).
    • Private ntfy servers: each user can supply their own credentials in Settings — either an access token or a username + password (HTTP basic). A user's own credentials take precedence over the admin's server-wide token. These are write-only (never returned to the browser).

Uploaded dashboard/tracker icons are stored under HM_UPLOAD_DIR (a Docker volume in the bundled compose) and served from /api/uploads/....


Backups (Borg)

Home Manager can back itself up to a remote Borg repository on a schedule, and restore from the UI. A separate backup container (backup/) does the work instead of the backend, because it has to stop and restart the backend.

A backup stops the backend (every container labelled homemanagement.backup.stop=true) and runs pg_dump inside the db container. It then creates an encrypted, deduplicated Borg archive of the dump plus the uploads volume, starts the backend again, and prunes old archives according to the retention settings. The app is unavailable while the snapshot is taken. After the first full backup, that's usually only seconds.

A restore can first take a safety backup of the current data (archive homemanagement-prerestore-…). It extracts the chosen archive and loads the dump into a scratch database while the app is still running. Only then does it stop the backend, swap the scratch database in for the live one, and replace the uploads directory. If the dump fails to load, live data is never touched.

Setup (Settings → Backups, admin only):

  1. Enter the repository URL (e.g. ssh://borg@nas.example.com:22/./homemanagement) and a passphrase, then save.
  2. Generate an SSH key (or paste an existing one). Add the public key to the backup user's ~/.ssh/authorized_keys on the repository server. Tip: prefix it with command="borg serve --restrict-to-path /srv/borg/homemanagement",restrict so the key can only run borg.
  3. For a new, empty location, click Initialize repository. For an existing repository, click Test connection.
  4. Click Export key and store the key with the passphrase somewhere other than this server.
  5. Set a schedule (times are in the app's timezone) and retention, or click Back up now.

The repository server needs borg 1.x (1.2 or newer); BorgBase and rsync.net provide it. Backup settings, job history and the SSH key live in the backup container's own backup-state volume, not in Postgres. A restore replaces the database, which would otherwise roll those settings back partway through the restore.

Disaster recovery on a new server: deploy the stack and sign in (the first user becomes admin). Enter the same repository URL and passphrase. Authorize the new SSH key, or paste the old private key. Then restore the newest backup. The restored database brings back all users and settings.

⚠️ The backup container mounts /var/run/docker.sock, which is root-equivalent access to the Docker host. It publishes no ports and only accepts requests carrying the token it shares with the backend through the backup-agent-token volume. Use one repository per install: after a crash, the agent breaks the repository's stale lock, assuming nothing else uses it.


Configuration reference

All backend settings are environment variables prefixed HM_ (see .env.example):

Variable Default Purpose
HM_DATABASE_URL local Postgres SQLAlchemy async URL
HM_CORS_ORIGINS http://localhost:5173 Allowed SPA origins (comma-separated)
HM_OIDC_ISSUER — Authentik provider issuer URL
HM_OIDC_AUDIENCE — Authentik Client ID (expected aud)
HM_OIDC_JWKS_URL derived from issuer Override JWKS endpoint if needed
HM_ADMIN_GROUP home-manager-admins OIDC group granting admin rights
HM_TRUST_FORWARD_AUTH false Trust X-authentik-* proxy headers
HM_DEV_AUTH false Dev only: bypass auth as a local admin
HM_UPLOAD_DIR /data/uploads Where uploaded icon images are stored
HM_ALERTS_ENABLED false Run the background tracker-alert scheduler
HM_ALERT_INTERVAL_MINUTES 60 How often to scan for due trackers
HM_SMTP_HOST / _PORT / _USER / _PASSWORD — SMTP server for email + SMS gateway
HM_SMTP_FROM Home Manager <…@localhost> From address on outgoing mail
HM_SMTP_STARTTLS / _SSL true / false SMTP transport security
HM_NTFY_SERVER https://ntfy.sh ntfy server for push notifications
HM_NTFY_TOKEN — Optional bearer token for a protected ntfy
HM_BACKUP_AGENT_URL http://backup:8080 Where the backup container listens (wiring only; backup settings are in the UI)
HM_BACKUP_AGENT_TOKEN_FILE /run/backup-agent/token Shared-volume token the backend uses to call it

API

Interactive docs at /docs when the backend is running. Endpoints are under /api: events, trackers (+ POST /api/trackers/{id}/done), goals, favorites (/categories and /items, each with /shares), dashboard-links, and users/me.

Schema note: tables are auto-created on startup, which is ideal for a single-household self-hosted app. If you later need versioned schema changes, add Alembic — the models live in backend/app/models.py.