SafeFamily is a personal family dashboard and parental control backend that combines URL monitoring, rule automation, and planning tools with a lightweight notesync API. It started as a safer-browsing tool and grew into a daily planner and notes system.
This repo is opinionated and assumes integrations like AdGuard Home and a router gateway. It is usable as a reference implementation even if you replace those integrations.
- URL log ingestion and analysis to surface suspicious domains.
- Admin UI to review suspicious entries and manage block lists and filter rules.
- Rule toggles for AdGuard and router traffic control with cooldown safeguards.
- Scheduler with APScheduler plus Postgres advisory locks for safe multi-process jobs.
- Notesync API with LWW conflict handling, tags, and media attachments.
- Todo planner with time slots and a completion heatmap.
- Notifications via email, Discord webhooks, and local Hammerspoon alerts.
- Auto Git export/import for block list rules.
- Nginx -> Gunicorn -> Flask -> PostgreSQL
- Integrations: AdGuard Home API, router gateway, SMTP, Discord, Hammerspoon, Git
- Python 3.11, Flask, Flask-SQLAlchemy, Flask-JWT-Extended
- APScheduler, pandas, psycopg2
- Jinja2 templates, hand-maintained plain CSS, pytest
src/safe_family/app.pyapp factory and blueprint registrationsrc/safe_family/api/notesync and auth exchange endpointssrc/safe_family/notesync/sync service and schemassrc/safe_family/urls/log receiver, analyzer, blocking, suspicious reviewsrc/safe_family/todo/planning UI and task managementsrc/safe_family/rules/scheduler and rule automationsrc/safe_family/notifications/email, Discord, and desktop alertssrc/safe_family/auto_git/rule export/import and auto-commitscripts/helper scripts,schema.sql(full DDL),db_export.py(backup/export)deploy/gunicorn, nginx, systemd configsdocs/API, architecture, and implementation notes
- Create a virtualenv and install dependencies:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .-
Configure environment variables (see Configuration).
-
Prepare the database.
scripts/schema.sqlcreates every table in one shot (both the SQLAlchemy ones and the raw-SQL ones), so it is the shortest path:
psql -d safefamily < scripts/schema.sqlRegenerate it from a live database with python scripts/db_export.py schema.
If you would rather build it piecemeal:
- SQLAlchemy models expect tables for users and notes.
- Notesync tables are provided in
scripts/notesync_schema.sql. - Other features use raw SQL tables (logs, suspicious, block_list, schedule_rules,
todo_list). See
docs/implementation.mdfor the table list.
- Run the app:
python run.pyor
flask --app src.safe_family.app runSafeFamily can be run as a multi-container application using Docker Compose.
Ensure you have a .env file configured (see Configuration). Then run:
docker-compose up -d --buildThis will start:
- db: PostgreSQL 15 (data persisted in
db_datavolume). - app: Flask backend running on Gunicorn.
- proxy: Nginx acting as a reverse proxy (handles SSL/HTTP).
On the first run — and only then, when the db_data volume is still empty — the
db service imports everything in deploy/init_db/. dump.sql there is a
schema-only copy of scripts/schema.sql, refreshed automatically by
python scripts/db_export.py schema, so it creates all 23 tables and carries no
rows and no credentials.
That directory is gitignored, so dump.sql is per-host and a fresh clone starts
without one. Seed it from the tracked schema before the first docker compose up,
or the database comes up empty — nothing in the app creates tables at runtime:
mkdir -p deploy/init_db && cp scripts/schema.sql deploy/init_db/dump.sqlFrom then on python scripts/db_export.py schema keeps it in step by itself.
After docker up, run docker exec safefamily_app python scripts/migrate.py. With
a schema-only dump.sql this is still required: migrate is a no-op for the DDL,
but it seeds the audit_item rows used by the /todo audit matrix.
A fresh instance has no users. Create the first one at POST /auth/register.
See Database Export and Import to restore real
data instead.
- Logs: Backend logs are mirrored to the local
./logsdirectory. - SSH Keys: Local
~/.sshis mounted read-only to/root/.sshin theappcontainer to support Git operations. - AdGuard Rules: The directory specified by
HOST_ADGUARD_RULE_PATHin your.envis mounted to allow rule manipulation.
- Start / Stop (daily use):
docker compose start/docker compose stop - View Logs:
docker-compose logs -f app - Restart App:
docker-compose restart app - Rebuild and Restart:
docker-compose up -d --build app - Run Migrations/CLI:
docker-compose exec app python -m safe_family.cli.analyze --range last_5min - Prune Cache (safe — never touches volumes):
Do not use
docker builder prune -af # build cache; regrows on every rebuild docker image prune -f # dangling/untagged layers only sudo journalctl --vacuum-size=500M sudo systemctl restart systemd-journald
docker image prune -ahere: theappimage is built locally and old builds are unrecoverable once pruned. To stop the journal regrowing, setSystemMaxUse=500Min/etc/systemd/journald.conf.
The db service maps db_data:/var/lib/postgresql/data. This is a named
volume, not a bind mount, so the two halves mean different things:
/var/lib/postgresql/data— PGDATA inside the container.db_data— a Docker-managed volume (declared under top-levelvolumes:), which Compose namespaces assafefamily_db_data.
Resolve the real host directory with:
docker volume inspect safefamily_db_data --format '{{.Mountpoint}}'On this host Docker is installed via snap, so the data root is
/var/snap/docker/common/var-lib-docker/... rather than the usual
/var/lib/docker/.... Don't assume the path — always inspect it. Note that
/var/lib/postgresql/14/main (a native apt Postgres install) is unrelated to
this stack.
Safe — data survives all of these: docker compose stop/start,
docker compose down then up, container recreation, image rebuilds, and the
prune commands listed above.
Destroys the database — never run these against this stack:
docker compose down -v # -v removes named volumes
docker volume rm safefamily_db_data
docker system prune --volumesBecause a named volume is less discoverable than a bind mount, take logical
backups rather than relying on the volume alone — see
Database Export and Import below. If you would
rather have the data at a visible host path, switch line 13 of
docker-compose.yml to a bind mount and migrate the existing contents.
Three artifacts, with different audiences:
| File | Contains | Shareable |
|---|---|---|
scripts/schema.sql |
DDL only — 23 tables, indexes, constraints, sequences. No rows. | Yes, it is tracked in git |
deploy/init_db/dump.sql |
The same DDL, for Docker first-boot. Gitignored, per-host. | Yes, same content |
backup/data-<date>.sql |
Rows, minus the bulky tables; credentials scrubbed | Yes, with the caveat below |
a plain pg_dump |
Everything, including live password hashes and emails | No |
python scripts/db_export.py # both files
python scripts/db_export.py schema # scripts/schema.sql (+ deploy/init_db/dump.sql)
python scripts/db_export.py data # backup/data-<date>.sql onlyThe schema export also mirrors itself into deploy/init_db/dump.sql whenever
that directory exists, so Docker's first-boot init cannot drift from the live
schema. It refuses to overwrite a dump.sql that has rows in it — a full
dump is somebody's backup and carries live password hashes — and says so instead
of clobbering it; move that file aside if you want it replaced. A schema-only
file is replaced whoever wrote it, since it is reproducible.
Run it from the host, not inside the app container. It shells into safefamily_db
and reads $POSTGRES_USER / $POSTGRES_DB there, so no source .env is needed
and the DB password never leaves the container.
What the data export does:
- Empties
logs,logs_daily,suspicious,media,token_blocklist,auth_codes— roughly 400 MB down to ~180 KB. It uses--exclude-table-data, not--exclude-table, so the tables are still created; that matters because the raw-SQL tables have no ORM to recreate them on the far side. - Scrubs
users.password_hashto!, andusers.emailtoz***a1b2c3@example.invalid. The blur is deterministic (stable diffs between runs) and distinct per input, which it has to be —users.emailis UNIQUE, so a flat mask would collide and break the restore. - Keeps the users rows.
todo_list,long_term_goals,long_term_goals_hisanduser_rule_assignmentall have FKs ontousers.id, andpg_dumpadds constraints after loading data, so dropping users entirely makes the restore die atADD CONSTRAINT. Scrubbing in place avoids that. - Verifies before it hands you the file. It reads the live hashes and emails
back out of Postgres and searches the finished dump for them. On any hit it
deletes the dump and exits non-zero. It also refuses to write if a scrubbed
column has been renamed, or if the users
COPYblock never appeared.
Add more columns to scrub by editing SCRUB in the script — e.g. notes.content
and todo_list task text are still plaintext, which is fine for your own backup
but not if the file leaves the machine.
For a full unsanitized dump (local disaster recovery only, never for sharing):
docker exec safefamily_db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
| gzip > backup/full-$(date +%F).sql.gzInto a scratch database, to test a backup without touching the live one:
docker exec safefamily_db sh -c 'psql -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE restore_test;"'
docker exec -i safefamily_db sh -c 'psql -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d restore_test' < scripts/schema.sql
docker exec -i safefamily_db sh -c 'psql -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d restore_test' < backup/data-2026-09-15.sql
docker exec safefamily_db sh -c 'psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE restore_test;"'Schema first, then data — the data dump is --data-only and has no CREATE TABLE.
ON_ERROR_STOP=1 makes psql abort on the first error instead of leaving a
half-restored database behind.
To replace the live database, stop the app first so nothing holds a connection:
docker compose stop app
docker exec safefamily_db sh -c 'psql -U "$POSTGRES_USER" -d postgres \
-c "DROP DATABASE \"$POSTGRES_DB\";" -c "CREATE DATABASE \"$POSTGRES_DB\";"'
docker exec -i safefamily_db sh -c 'psql -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < scripts/schema.sql
docker exec -i safefamily_db sh -c 'psql -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup/data-2026-09-15.sql
docker compose start app
docker exec safefamily_app python scripts/migrate.pyRestoring a full gzipped dump instead (it already contains its own DDL, so no
schema.sql step):
gunzip -c backup/full-2026-09-16.sql.gz \
| docker exec -i safefamily_db sh -c 'psql -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'- Restore inside the container, not with the host's psql. These dumps come
from pg_dump 15.16 and open with
\restrict, which the host's psql 14.24 does not recognise (invalid command \restrict).scripts/schema.sqlis written with those lines stripped, so it loads under either client;backup/data-*.sqlkeeps them on purpose, since they are what stops psql meta-commands smuggled in through dumped row data. - After a sanitized restore nobody can log in — every
password_hashis!. Set one:docker exec safefamily_app python -c " from src.safe_family.app import create_app from src.safe_family.core.models import User app = create_app() with app.app_context(): user = User.get_user_by_username('zz1') user.set_password('choose-a-new-one') user.save() "
- The emptied tables restore as empty tables, not missing ones.
/suspiciousand the analyzer pages will simply have no history until new AdGuard logs arrive. backup/is gitignored, as isdeploy/init_db/.
- Snap auto-refreshes Docker several times a day (
snap refresh --time). A refresh restarts the daemon and briefly stops containers;restart: alwaysbrings them back, but the timing is not under your control. Usesnap refresh --holdor a maintenance window if that matters. - Postgres publishes
5432on all interfaces (0.0.0.0), not just localhost — intentional for remote pgAdmin, but it should be firewalled.
Run the full suite with coverage enforcement:
pytest -qRun a targeted test without coverage (useful for quick iterations):
pytest -q tests/test_misc_routes.py::test_notes_media_public_note_for_other_user --no-covRun with coverage but disable the fail-under gate:
pytest -q tests/test_misc_routes.py::test_notes_media_public_note_for_other_user --cov-fail-under=0src/safe_family/static/css/styles.css is hand-maintained plain CSS — edit it directly, no build step needed.
SafeFamily reads environment variables (dotenv supported).
Minimum for local development:
FLASK_SQLALCHEMY_DATABASE_URIFLASK_APP_SECRET_KEYFLASK_JWT_SECRET_KEYDB_PARAMS(JSON string used by psycopg2, required by log, todo, and rules features)
Example DB_PARAMS:
{"dbname":"safefamily","user":"safefamily","password":"secret","host":"localhost","port":5432}
Notesync:
NOTESYNC_API_KEYNOTESYNC_AUTH_CODE_TTL_SECONDSNOTESYNC_MAX_REQUEST_BYTESNOTESYNC_CALLBACK_URL
Integrations (optional, feature-specific):
- AdGuard Home:
ADGUARD_HOSTPORT,ADGUARD_USERNAME,ADGUARD_PASSWORD,ADGUARD_RULE_PATH - Router:
ROUTER_IP - Email:
MAIL_ACCOUNT,MAIL_PASSWORD,MAIL_PERSON_LIST - Discord:
DISCORD_WEBHOOK_URL - Hammerspoon:
HAMMERSPOON_ALERT_URL - OAuth:
GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_CLIENT_PROJECT_ID,GOOGLE_CALLBACK_ROUTE
- Notesync endpoints live under
/apiand requireX-API-Key+ JWT. - See
docs/api.mdfor request/response examples.
python -m safe_family.cli.analyze --range last_5minruns log analysis.- Scheduled rules are managed in the UI and executed by APScheduler
(see
src/safe_family/rules/scheduler.py).
- See
INSTALL.mdfor full setup steps. - Nginx/Gunicorn/systemd configs live in
deploy/. - Architecture overview:
docs/architecture.md.
MIT. See LICENSE.