Skip to content

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 see docs/technical/creating-modules.md.


1. Prerequisites ​

  • Docker + Docker Compose on the host.
  • Cloned DentalPin repo (or equivalent deploy artefacts).
  • .env filled in with POSTGRES_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 .env must set COOKIE_DOMAIN to that parent (COOKIE_DOMAIN=.example.com for app.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 the dp_csrf cookie either. The backend logs a warning naming the value to set. The parent must be a domain you own. Hosting domains such as onrender.com, vercel.app, herokuapp.com or sslip.io are public suffixes: browsers treat each subdomain as a different site and reject COOKIE_DOMAIN=.onrender.com, so login answers 200 and /auth/me 401. Put a custom domain on both services, or serve them from one origin.
  • No custom domain (Render, Railway, Vercel…)? Let the frontend proxy /api/v1 to 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) and NUXT_API_BASE_URL_SERVER=https://<api-host>. On the backend: ALLOWED_ORIGINS=https://<app-host>, ENVIRONMENT=production, COOKIE_DOMAIN empty, and FORWARDED_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's client_ip both come from X-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 add 10.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:

bash
curl -s http://localhost:8000/health
# {"status":"healthy","version":"0.1.0"}

2. Daily operations ​

Listing modules ​

bash
./bin/dentalpin modules list

Output (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 ​

bash
./bin/dentalpin modules info billing

Shows version, state, applied revision, last state change, errors. JSON form: same command with --json.

Status summary ​

bash
./bin/dentalpin modules status

Counts by state plus pending + errored lists.

Health check ​

bash
./bin/dentalpin modules doctor

Surfaces:

  • Orphans — rows in core_module whose 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:

bash
./bin/dentalpin modules install billing
./bin/dentalpin modules restart

Community modules ​

bash
# 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 frontend

Output of step 2 lists the dependency chain that will be touched:

Scheduled for install on next restart:
  - clinical
  - my_module

4. Upgrading a module ​

Bump the module's package (pip install -U ...) so the new version appears on disk, then:

bash
./bin/dentalpin modules upgrade my_module
./bin/dentalpin modules restart

The 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 ​

bash
./bin/dentalpin modules uninstall my_module
docker compose restart backend

The restart:

  1. Backs up every table the module owns to storage/backups/module_<name>_<timestamp>.sql via pg_dump --data-only.
  2. Calls the module's uninstall(ctx) hook.
  3. Deletes every record tracked via core_external_id.
  4. Runs alembic downgrade <module>@base — reverts the module's branch only.
  5. Flips core_module.state = uninstalled.

Why some modules refuse to uninstall ​

Two guardrails:

  • removable: false in the manifest (officials). Override with --force if 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):

ChannelCommand
CLI hint./bin/dentalpin modules restart prints next step
RESTPOST /api/v1/modules/-/restart (admin token)
Hostdocker 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:

bash
docker compose build frontend && docker compose up -d frontend

30-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:

  1. Inspect frontend/modules.json — it should list the module's layer path.
  2. Run ./bin/dentalpin modules sync-frontend to regenerate the file.
  3. 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:

bash
docker compose exec backend ls -l /app/storage/backups

Each uninstall produces one .sql file with INSERTs for every table the module owned. Restore with:

bash
docker compose exec -T db psql -U dental -d dental_clinic \
  < storage/backups/module_my_module_20260420T080000Z.sql

The schema must already exist (reinstall the module first, then restore data).

Full backup (database + files) ​

bash
docker compose exec -T backend python -m app.cli db backup

Produces 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:

  1. Fix the root cause (usually a migration or seed bug), then dentalpin modules install <name> + restart to retry.
  2. 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.

bash
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):
  - ghost

Options:

  • 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:

sql
-- 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 ​

SymptomLikely causeFix
relation "core_module" does not existTests dropped all tablesdocker compose exec backend alembic upgrade head
Module stuck in to_installCrash mid-stepInspect core_module_operation_log, restart backend
403 Permission denied: billing.readRole lacks the permissionCheck ROLE_PERMISSIONS, confirm cached /me
Frontend sidebar empty/api/v1/modules/-/active failed (bad token?)Check browser console, re-login
Community module page 404modules.json missing the layer path./bin/dentalpin modules sync-frontend + frontend rebuild
Uninstall blocked: "no Alembic branch"Fase A legacy moduleNot supported; wait for Fase B
Uninstall blocked: "required by ..."Reverse dependency existsUninstall dependents first, or --force
Logged out on every page refresh, while clicking around worksApp and API on different hosts with COOKIE_DOMAIN empty, so the session cookies never reach the appSet 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 worksSame cause: the app cannot read the dp_csrf cookie, so the X-CSRF-Token header is never sentSame fix: set COOKIE_DOMAIN to the shared parent domain
Login answers 200, then /auth/me 401 and back to the login screenApp and API on a hosting domain that is a public suffix (*.onrender.com, *.vercel.app…): the browser rejects the cookiesPut 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:

bash
./bin/dentalpin modules doctor --json
./bin/dentalpin modules info <affected-module> --json
docker compose logs backend --tail 100

For 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 need admin.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_dump backup (§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 /-/status until pending=[], then refreshes the list and the sidebar. A banner at the top always shows pending modules.
  • Doctor banner: when GET /-/doctor reports 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.