apps.header.allAppsCount
Home-Ops
apps.detail.types.app from gobrowns' Repository
apps.detail.sections.overview
Readme
View on GitHubHome Ops
A self-hosted household management system: chores, a shared calendar, recipes and meal planning, groceries, contacts, notes and health records — one private application on hardware you own.
It is built for a household rather than a company, which mostly shows up in one place: members have different levels of trust. A teenager, a housemate and a grandparent should not see the same things, and "the admin can see everything" is the wrong answer for a family's medical notes. So every record carries its own visibility, and there is no administrator override on any of it.
Runs as one Docker container — application, web server and database together — with a single volume and a single port.
What it does
| Module | What it covers |
|---|---|
| Tasks | Assignments, categories, priorities, subtasks, recurring chores |
| Notes | A shared noticeboard with tags, pinning and a shared order |
| Calendar | Month and agenda views, RFC 5545 recurrence, three edit scopes, drag to move |
| Kitchen | Recipes with structured ingredients, import from a web page or a Mealie backup, meal planning |
| Shopping | Many lists, per-list visibility, transfers between them, generated from the meal plan |
| Contacts | A household directory with vCard import and export |
| Health | Vitals, medications, lab results and activity, shared per person |
Two ideas run through all of it. Every member owns a colour that follows them into every list, so you find your own row before you read a word. And visibility is per record — private, shared with named people, or the whole household — enforced on every read path.
Requirements
- Docker, with Compose v2 (
docker compose, notdocker-compose) - About 1 GB of disk for the image, plus whatever your data comes to
- No cloud account, no API key, no external service
Installation
1. Set a database password. Do this before the first start — Postgres bakes it into the data directory on first boot, and changing it afterwards does not change the user.
cp .env.example .env
Then edit .env and set:
POSTGRES_PASSWORD=<something long and random>
RUN_MIGRATIONS=true
APP_HOSTS=localhost,192.168.1.50 # your server's LAN address
RUN_MIGRATIONS=true matters: it creates the database schema. Left off, the app
starts but has no tables, and the first page fails.
2. Start it.
docker compose up -d --build
The first build takes a few minutes. If your Docker's Compose has trouble building, build the image directly and then start it:
docker build -t home-ops:single .
docker compose up -d
3. Open it at http://localhost:8080 — or the address in APP_HOSTS, on the
port in WEB_PORT.
The first visit shows a setup page, because no account exists yet. Whoever completes it becomes the administrator. That page stops existing once there is a user.
Configuration
.env is written so each value appears once. Four lines cover a normal install:
POSTGRES_PASSWORD |
Set before the first start. Any characters; it is escaped wherever it is used. |
WEB_PORT |
The port the app is served on. Written here and nowhere else. |
APP_HOSTS |
Every hostname and LAN address you will reach it on — names only, comma separated. Each becomes an allowed origin. |
PUBLIC_BASE_URL |
Your HTTPS address, if you put it behind a tunnel or a reverse proxy. |
DATABASE_URL and TRUSTED_ORIGINS are assembled from those and should stay
blank unless you need something they cannot express.
Reaching it over HTTPS
The app is designed to answer on several addresses at once — a public HTTPS hostname, a VPN name, and a LAN address — because a household reaches it from different places. Two consequences:
SESSION_COOKIE_SECUREdefaults to false, because aSecurecookie is silently dropped over plain HTTP and would lock out the LAN. Set it true only if every path is HTTPS.- TLS is not handled here. Put a tunnel or a reverse proxy in front and add the
hostname to
PUBLIC_BASE_URL.
Everyday operation
Update to a newer version. Your data lives in a Docker volume, not the image, so replacing the image keeps it:
docker compose down
docker compose up -d --build
docker compose exec app home-ops-migrate # if the schema changed
Apply migrations by hand (what RUN_MIGRATIONS=true does for you on start):
docker compose exec app home-ops-migrate
docker compose exec app home-ops-migrate current
Back it up. Everything is under /data in the one volume — the database and
the uploaded recipe images together:
docker run --rm -v home-ops_home_ops_data:/data -v "$PWD:/out" \
alpine tar czf /out/home-ops-backup.tar.gz -C /data .
A database dump on its own is not a complete backup: recipe images live beside it. Keep the archive somewhere other than the machine it came from.
Logs.
docker compose logs -f app
Troubleshooting
The first page errors, or nothing loads. The schema was never created. Set
RUN_MIGRATIONS=true in .env and restart, or run
docker compose exec app home-ops-migrate.
"Password authentication failed" in the logs. POSTGRES_PASSWORD was changed
after the first start. Postgres fixed it at initialisation. Either put the
original back, or start over with a fresh volume
(docker compose down -v — this deletes your data).
Locked out of the app. Five failed sign-ins for one username in fifteen minutes locks that name out; it clears itself after the window. The message for that is "Too many failed attempts", which is different from a wrong password.
Port already in use. Change WEB_PORT in .env.
Nothing at your LAN address, but localhost works. Add that address to
APP_HOSTS and restart. Origins are explicit on purpose — the app never trusts
the Host header to work out what it is called.
How it is built
Python 3.12 + FastAPI · PostgreSQL 16 · React 19 + TypeScript + Vite · SQLAlchemy 2 + Alembic · nginx, supervised by s6-overlay. No cloud services at runtime, no telemetry, no Redis.
One container holds all three processes with one volume at /data. nginx serves
the built frontend and proxies /api to the application over loopback, so the
browser only ever sees one origin and every URL in the app is relative.
Licence
MIT — see LICENSE.
apps.marketingCta.appInstallTitle
apps.marketingCta.appInstallDescription
apps.detail.sections.requirements
apps.detail.sections.categories
apps.detail.sections.related
apps.detail.related.exploreCategories
apps.detail.related.exploreAllapps.detail.sections.links
apps.detail.sections.details
ghcr.io/g0browns/home-ops:latestapps.detail.sections.runtime
- apps.detail.details.webui
http://[IP]:[PORT:8080]- apps.detail.details.network
bridge- apps.detail.details.shell
bash- apps.detail.details.privileged
- false
apps.detail.sections.configuration
Host port for the web interface. The container always listens on 8080. If you change the host port, set WEB_PORT below to the same number.
- apps.detail.config.target
- 8080
- apps.detail.config.default
- 8080
- apps.detail.config.value
- 8080
Everything persistent: the PostgreSQL cluster (/data/postgres) and uploaded recipe images (/data/uploads). Put this on a cache pool — e.g. /mnt/cache/appdata/home-ops — rather than a /mnt/user fuse path, which PostgreSQL does not tolerate well. A database dump alone is NOT a complete backup; back up this whole directory.
- apps.detail.config.target
- /data
- apps.detail.config.default
- /mnt/user/appdata/home-ops
- apps.detail.config.value
- /mnt/user/appdata/home-ops
Password for the bundled database. SET THIS BEFORE THE FIRST START: PostgreSQL bakes it into the data directory on first boot, and changing it later does not re-create the user. Any characters are fine; it is escaped wherever it is used.
Apply database migrations on start. Leave this true. With it off the app starts but has no tables, and the first page fails. Turn it off only if you prefer to run 'home-ops-migrate' by hand from the container console before each upgrade.
- apps.detail.config.default
- true
- apps.detail.config.value
- true
Every hostname and IP you will open the app on over plain HTTP — your Unraid server's LAN address, a Tailscale name, and so on. NAMES ONLY: no scheme, no port, comma separated. Each becomes an allowed origin on WEB_PORT, which is what lets a browser at that address log in. Example: localhost,192.168.1.10,tower.local
- apps.detail.config.default
- localhost
- apps.detail.config.value
- localhost
The HOST port you mapped above. It is not used to bind anything — it is used to build the allowed browser origins from APP_HOSTS. If this does not match the port in your address bar, sign-in fails on that address.
- apps.detail.config.default
- 8080
- apps.detail.config.value
- 8080
IANA timezone name, e.g. Europe/London or America/New_York. Used for log timestamps and scheduling. Never an offset like +01:00 — offsets cannot survive daylight saving.
- apps.detail.config.default
- UTC
- apps.detail.config.value
- UTC
Your HTTPS address, if you put the app behind a reverse proxy or a Cloudflare Tunnel, e.g. https://home.example.com. It becomes an allowed origin and is used for links that leave the app. Leave blank if you have no proxy.
Mark the session cookie Secure. Keep this false unless EVERY path to the app is HTTPS — a Secure cookie is silently dropped over plain HTTP, which would lock out your LAN address.
- apps.detail.config.default
- false
- apps.detail.config.value
- false
Whether the first-run setup wizard may be completed over the public HTTPS path. While no user exists that page is unauthenticated by nature — whoever reaches it first becomes the administrator — so it is restricted to the LAN by default. It has no effect once any user exists.
- apps.detail.config.default
- false
- apps.detail.config.value
- false
CRITICAL, ERROR, WARNING, INFO or DEBUG.
- apps.detail.config.default
- INFO
- apps.detail.config.value
- INFO
Largest single image upload accepted, in bytes, before decoding. Default is 10 MB.
- apps.detail.config.default
- 10485760
- apps.detail.config.value
- 10485760
Leave as production. 'development' enables the /api/docs page and verbose error responses.
- apps.detail.config.default
- production
- apps.detail.config.value
- production
Peers whose X-Forwarded-For / X-Real-IP headers are honoured. nginx and the API talk over loopback inside this container, so 127.0.0.1/32 is the whole list. Empty means client IPs are all logged as 127.0.0.1.
- apps.detail.config.default
- 127.0.0.1/32
- apps.detail.config.value
- 127.0.0.1/32
Leave blank to use the PostgreSQL bundled in this container. Set it only to point at a PostgreSQL 16 server you run elsewhere (postgresql+psycopg://user:pass@host:5432/home_ops); the bundled one then simply goes unused. Percent-encode any @ in the password.
When set, GET /api/health/ready requires a matching X-Healthcheck-Token header. Leave blank on a private network. The plain /api/health liveness endpoint stays public either way and discloses nothing.