Operating a DentalPin instance
Guide for admins and self-hosters: install and remove modules, trigger restarts, take backups, recover from errors. Covers the commands an operator runs — not the Python internals.
Audience: ops-aware user with shell access to the host running
docker compose up. For contributor-facing docs seedocs/technical/creating-modules.md.
1. Prerequisites
- Docker + Docker Compose on the host.
- Cloned DentalPin repo (or equivalent deploy artefacts).
.envfilled in withPOSTGRES_PASSWORD,SECRET_KEY, etc.- A running stack:
docker compose up -d. - Serving the app and the API from different hosts? Then they must be siblings of one parent domain and
.envmust setCOOKIE_DOMAINto that parent (COOKIE_DOMAIN=.example.comforapp.example.com+api.example.com). The session cookies are host-only without it, so the server-rendered pages never see the session, every reload logs the user out, and every write fails with "CSRF token missing" because the app cannot read thedp_csrfcookie either. The backend logs a warning naming the value to set. The parent must be a domain you own. Hosting domains such asonrender.com,vercel.app,herokuapp.comorsslip.ioare public suffixes: browsers treat each subdomain as a different site and rejectCOOKIE_DOMAIN=.onrender.com, so login answers 200 and/auth/me401. Put a custom domain on both services, or serve them from one origin. - No custom domain (Render, Railway, Vercel…)? Let the frontend proxy
/api/v1to the backend, so the browser only ever talks to the app's origin and the cookies are host-only. On the frontend service:NUXT_API_PROXY=true,NUXT_PUBLIC_API_BASE_URL=https://<app-host>(the app's own URL) andNUXT_API_BASE_URL_SERVER=https://<api-host>. On the backend:ALLOWED_ORIGINS=https://<app-host>,ENVIRONMENT=production,COOKIE_DOMAINempty, andFORWARDED_ALLOW_IPS(below) — the API host stays reachable directly, so this one matters here. FORWARDED_ALLOW_IPS— which proxies may set the client IP. The login rate limit and the session'sclient_ipboth come fromX-Forwarded-For, which is a header the client can write. uvicorn only reads it from peers in this list, walking the header right-to-left and taking the first address not in the list as the real client. So list every hop between the browser and the backend: leave one out and it becomes the "client", putting every request behind it in one rate-limit bucket. Never*— that trusts every peer and then takes the leftmost entry, the one the caller wrote, so anyone who can reach the port picks their own bucket and their own audit IP. The default (127.0.0.1,::1,172.16.0.0/12,192.168.0.0/16) covers Docker's address pools, which is right for compose and Coolify. Swarm or Kubernetes add10.0.0.0/8. With the proxy above, the nearest hop is the frontend container and the platform's edge sits between it and the browser, so both ranges belong in the list. The backend logs the effective value at startup — read that line after any deployment change, because both mistakes are silent.
Smoke-check:
curl -s http://localhost:8000/health
# {"status":"healthy","version":"0.1.0"}2. Daily operations
Listing modules
./bin/dentalpin modules listOutput (trimmed):
NAME VERSION STATE CATEGORY DEPENDS
billing 0.1.0 installed official clinical,catalog,budget
budget 0.1.0 installed official clinical,catalog
clinical 0.1.0 installed official -JSON variant: ./bin/dentalpin modules list --json.
Inspecting one module
./bin/dentalpin modules info billingShows version, state, applied revision, last state change, errors. JSON form: same command with --json.
Status summary
./bin/dentalpin modules statusCounts by state plus pending + errored lists.
Health check
./bin/dentalpin modules doctorSurfaces:
- Orphans — rows in
core_modulewhose code disappeared from disk. - Missing dependencies — module X depends on Y that wasn't found.
- Manifest errors — schema violations in
MANIFEST. - Errored modules — rows where the last install/upgrade step failed.
Exit code is non-zero when any issue is reported; ideal for monitoring scripts.
3. Installing a module
Official modules
Bundled with every DentalPin release. They auto-install on the first boot of a fresh database. Reinstall if they ended up in uninstalled:
./bin/dentalpin modules install billing
./bin/dentalpin modules restartCommunity modules
# 1. Install the Python package on the backend container
docker compose exec backend pip install dentalpin-my-module
# 2. Schedule the install
./bin/dentalpin modules install my_module
# 3. Restart the backend — applies migrations + seed + lifecycle
docker compose restart backend
# or: POST /api/v1/modules/-/restart
# 4. Rebuild the frontend if the module ships a Nuxt layer
docker compose build frontend && docker compose up -d frontendOutput of step 2 lists the dependency chain that will be touched:
Scheduled for install on next restart:
- clinical
- my_module4. Upgrading a module
Bump the module's package (pip install -U ...) so the new version appears on disk, then:
./bin/dentalpin modules upgrade my_module
./bin/dentalpin modules restartThe restart runs the new migrations, re-applies seeds, and calls the module's post_upgrade(ctx, from_version) hook.
If the disk and DB versions already match, the command exits with "Module is already at the declared version."
5. Uninstalling a module
./bin/dentalpin modules uninstall my_module
docker compose restart backendThe restart:
- Backs up every table the module owns to
storage/backups/module_<name>_<timestamp>.sqlviapg_dump --data-only. - Calls the module's
uninstall(ctx)hook. - Deletes every record tracked via
core_external_id. - Runs
alembic downgrade <module>@base— reverts the module's branch only. - Flips
core_module.state = uninstalled.
Why some modules refuse to uninstall
Two guardrails:
removable: falsein the manifest (officials). Override with--forceif you really mean it.- No Alembic branch (Fase A legacy modules). These cannot downgrade cleanly; uninstall is blocked even with
--force. Wait for Fase B. - Reverse dependencies — another installed module lists this one in its
depends. Uninstall them first, or pass--force.
6. Restarts
Three ways, identical effect (SIGTERM the backend, let Docker respawn):
| Channel | Command |
|---|---|
| CLI hint | ./bin/dentalpin modules restart prints next step |
| REST | POST /api/v1/modules/-/restart (admin token) |
| Host | docker compose restart backend |
Respawn takes 3-5 seconds. Lifespan processes every to_* row in topological order before accepting traffic. Errors per module are recorded in core_module.error_message; the rest of the stack still comes up.
7. Frontend rebuilds
Community modules with a Nuxt layer require a frontend rebuild:
docker compose build frontend && docker compose up -d frontend30-60s downtime on the UI. Official modules don't need a rebuild — they're already in the bundle; toggling visibility is a filter on /api/v1/modules/-/active.
If the frontend starts but a module doesn't appear:
- Inspect
frontend/modules.json— it should list the module's layer path. - Run
./bin/dentalpin modules sync-frontendto regenerate the file. - Confirm the user has the permission listed in
navigation[].permission.
8. Backups
Module-scoped backups live under storage/backups/ inside the backend's storage_data volume:
docker compose exec backend ls -l /app/storage/backupsEach uninstall produces one .sql file with INSERTs for every table the module owned. Restore with:
docker compose exec -T db psql -U dental -d dental_clinic \
< storage/backups/module_my_module_20260420T080000Z.sqlThe schema must already exist (reinstall the module first, then restore data).
Full backup (database + files)
docker compose exec -T backend python -m app.cli db backupProduces full_<timestamp>.dump (whole database) and storage_<timestamp>.tar.gz (documents, X-rays, imports) under storage/backups/. Schedule this nightly and copy the files off the server: a backup on the same disk is not a backup. Full restore, hardware-migration, and monthly-verification procedure: docs/workflows/backup-restore.md.
9. Recovery
A failed install
dentalpin modules doctor lists the module with its error. Options:
- Fix the root cause (usually a migration or seed bug), then
dentalpin modules install <name>+ restart to retry. - Give up:
dentalpin modules orphan <name>(marks uninstalled without running the uninstall flow). Only do this when the module wasn't actually present on disk.
A stuck to_install
Happens when a crash takes down the backend mid-step. Restart — the lifespan processor retries from the first incomplete step. Idempotent design means migrate/seed/lifecycle are safe to re-run.
docker compose exec backend \
psql -U dental -d dental_clinic \
-c "SELECT * FROM core_module_operation_log ORDER BY id DESC LIMIT 20;"An orphan row
Module code was removed from disk without an uninstall (e.g. someone uninstalled the Python package directly). Bootstrap will fail loud:
Orphan modules (in DB, missing from disk):
- ghostOptions:
Restore the package (
pip install dentalpin-ghost).Mark uninstalled:
bash./bin/dentalpin modules orphan ghost
The orphan path does not run the uninstall steps (migrations are not downgraded, external ids are not purged, no backup is taken). Use with care.
Permission denied after role change
Clear the user's auth cookies and re-login. Permissions are loaded from /me at login time; stale sessions keep the old list.
10. SQL quick reference
Run inside docker compose exec db psql -U dental -d dental_clinic:
-- All modules + state + error
SELECT name, state, version, installed_at, error_message
FROM core_module
ORDER BY name;
-- Pending operations
SELECT name, state, last_state_change
FROM core_module
WHERE state LIKE 'to_%';
-- Recent operation log
SELECT module_name, operation, step, status, created_at
FROM core_module_operation_log
ORDER BY id DESC
LIMIT 25;
-- Seed records tracked for a module
SELECT xml_id, table_name, record_id, noupdate
FROM core_external_id
WHERE module_name = 'inventory';
-- Clear the Alembic pointer (dangerous — used by reset-db.sh)
DELETE FROM alembic_version;11. Troubleshooting table
| Symptom | Likely cause | Fix |
|---|---|---|
relation "core_module" does not exist | Tests dropped all tables | docker compose exec backend alembic upgrade head |
Module stuck in to_install | Crash mid-step | Inspect core_module_operation_log, restart backend |
403 Permission denied: billing.read | Role lacks the permission | Check ROLE_PERMISSIONS, confirm cached /me |
| Frontend sidebar empty | /api/v1/modules/-/active failed (bad token?) | Check browser console, re-login |
| Community module page 404 | modules.json missing the layer path | ./bin/dentalpin modules sync-frontend + frontend rebuild |
| Uninstall blocked: "no Alembic branch" | Fase A legacy module | Not supported; wait for Fase B |
| Uninstall blocked: "required by ..." | Reverse dependency exists | Uninstall dependents first, or --force |
| Logged out on every page refresh, while clicking around works | App and API on different hosts with COOKIE_DOMAIN empty, so the session cookies never reach the app | Set COOKIE_DOMAIN to the shared parent domain (e.g. .example.com) and restart the backend |
Every save answers 403 CSRF token missing or invalid, while reading works | Same cause: the app cannot read the dp_csrf cookie, so the X-CSRF-Token header is never sent | Same fix: set COOKIE_DOMAIN to the shared parent domain |
Login answers 200, then /auth/me 401 and back to the login screen | App and API on a hosting domain that is a public suffix (*.onrender.com, *.vercel.app…): the browser rejects the cookies | Put a custom domain on both (app.example.com + api.example.com) and set COOKIE_DOMAIN=.example.com, or turn on the frontend's same-origin proxy (NUXT_API_PROXY=true, §1) |
12. Where to file bugs
GitHub: https://github.com/dentalpin/dentalpin/issues — include the output of:
./bin/dentalpin modules doctor --json
./bin/dentalpin modules info <affected-module> --json
docker compose logs backend --tail 100For security reports contact the maintainers privately rather than opening a public issue.
13. Module manager UI (/settings/modules)
Everything in §§2–6 is also available as an admin web page — no shell access needed. Open Settings → Modules (/settings/modules).
- Gating: viewing the page needs
admin.clinic.read; the Install / Uninstall / Upgrade / Apply buttons needadmin.clinic.write. Other roles see an "access denied" message. - Module list: every discovered module with its state (
installed,uninstalled,to_install,to_upgrade,to_remove,disabled,error), version, category badge (official/community), dependencies and summary. - Find & paginate: the list has a search box and a state filter (installed / uninstalled / pending / disabled / error), showing twenty modules per page. The query (
?q=…,?states=…,?page=…) is synced to the URL, so a filtered view can be bookmarked or linked. - Install: offered for uninstalled, installable modules present on disk. The confirmation modal previews the transitive dependency chain that will be scheduled.
- Upgrade: offered for installed modules whose on-disk manifest version differs from the installed version (shown as "installed → on-disk"). The confirmation modal schedules the upgrade; like installs, it needs an Apply + restart (below) to run. Dependency reachability is enforced by Install, not by this signal.
- Uninstall: offered for installed, removable modules. The modal warns about the automatic
pg_dumpbackup (§8). When reverse dependencies block the removal, the error renders inline with an option to retry with force. - Apply changes: scheduled operations wait as pending until you press Apply. The backend restarts, the page polls
GET /-/statusuntilpending=[], then refreshes the list and the sidebar. A banner at the top always shows pending modules. - Doctor banner: when
GET /-/doctorreports orphans, missing dependencies, manifest errors or errored modules, a banner summarises them at the top of the page. - Detail drawer: per module, key facts (state, category, install date, revisions), the error message if any, the recent operation log (migrate → seed → lifecycle → finalize per operation) and the raw manifest snapshot.