Operating Tangible for one or more users: first-run setup, accounts, SSO, reverse proxies, backups, upgrades, observability, and routine maintenance.
Installing the server for the first time? Start with the Deployment guide. Looking up a specific environment variable? See the Configuration reference. End-user documentation lives in the User guide.
- First-run setup
- Users and roles
- MCP server (AI assistant integration)
- SSO / OIDC
- Reverse proxy setup
- Backups
- Upgrading
- Observability
- Rate limiting
- Storage limits
- Routine tasks
- Troubleshooting
- Getting help
The first time the container starts:
- The schema is created and Alembic migrations run automatically
(unless
TANGIBLE_DB_AUTO_MIGRATE=false). - If
TANGIBLE_ADMIN_USERNAMEandTANGIBLE_ADMIN_PASSWORDare set in the environment, that admin account is created automatically. Pair withTANGIBLE_ADMIN_PASSWORD_FILEfor Docker secrets / Unraid file mounts. - Otherwise, browse to the URL and click Register. The very first
user to sign up is automatically promoted to admin — even when
TANGIBLE_REGISTRATION_ENABLED=false. After that initial signup, the normalTANGIBLE_REGISTRATION_ENABLEDrule applies and further public registration is rejected with HTTP 403 unless you explicitly enable it.
Once an admin exists, self-registration is off by default. Set
TANGIBLE_REGISTRATION_ENABLED=true to allow open signup, or invite users
individually (see below).
Tangible has two layers of authorization:
- Global role —
adminoruser. Admins can manage users, invitations, and audit logs; ordinary users only see their own collections plus collections shared with them. - Per-collection role —
viewer,editor,owner. Granted by the collection's owner(s) via member invites or share links.
From Admin → Users → Invite user:
- Enter an email address (or copy the generated link to send out-of- band).
- Pick the global role (
useris normally what you want) and an optional starter collection + role. - Send the link. The recipient signs up and lands directly in any collection you pre-assigned.
Invitations expire (default 7 days) and can be revoked from the same page.
There is no email-based reset flow yet. To reset a forgotten password:
docker exec -it tangible tangible bootstrap-admin # only re-creates if missingFor other users, use the admin UI's Reset password action, which sets a one-time password the user must change on next login.
Disable from the admin UI to keep their content (loans, audit trail) intact. Deleting a user transfers ownership of their collections to the deleting admin and removes their sessions and tokens.
Admins can edit all meaningful TANGIBLE_* configuration knobs without
restarting the container via Settings → Server Settings (admin-only).
- Security, sessions, integrations, SMTP, and storage settings are
presented as labeled fields. Each field shows its current source
(
database/environment/default) and the corresponding env var name. Sensitive values are masked. - Changes written here are stored in the
app_settingstable and take precedence over environment variables instantly, with no restart needed. - To revert to the environment-variable value, clear the field in the UI
or remove the row from
app_settingsin the database.
Toggle Require 2FA in the Server Settings panel (or set
TANGIBLE_REQUIRE_2FA=true in the environment). Once enabled:
- Any user who has not enrolled TOTP is redirected to Settings on every page load and shown an enrollment prompt until setup is complete.
GET /auth/mereturnsenrollment_required: truefor unenrolled users; clients use this to surface the prompt without waiting for a redirect.- Already-enrolled users are not affected.
- Admins are subject to the same rule.
Tangible speaks OpenID Connect via Authlib. Multiple providers can be
enabled simultaneously (e.g. Authentik and Google). Set
TANGIBLE_OIDC_ENABLED=true to activate the system, then configure one
or more providers as shown below.
Common env vars that apply to all providers:
| Variable | Default | Notes |
|---|---|---|
TANGIBLE_OIDC_ENABLED |
false |
Master switch |
TANGIBLE_OIDC_AUTO_CREATE_USERS |
true |
Create local account on first login |
TANGIBLE_OIDC_DEFAULT_ROLE |
user |
Role for auto-created users (user or admin) |
Create an OAuth2/OIDC provider in Authentik. Set the redirect URI to
https://tangible.example.com/auth/oidc/authentik/callback.
TANGIBLE_OIDC_AUTHENTIK_DISPLAY_NAME=Authentik
TANGIBLE_OIDC_AUTHENTIK_ISSUER=https://auth.example.com/application/o/tangible/
TANGIBLE_OIDC_AUTHENTIK_CLIENT_ID=tangible
TANGIBLE_OIDC_AUTHENTIK_CLIENT_SECRET_FILE=/run/secrets/authentik_secret
TANGIBLE_OIDC_AUTHENTIK_ADMIN_GROUPS=tangible-adminsADMIN_GROUPS (comma-separated group claims) auto-promotes anyone in
those groups to global admin. If not set, all OIDC users get
TANGIBLE_OIDC_DEFAULT_ROLE.
Create a client in your Keycloak realm. Set Access Type to
confidential and add the redirect URI
https://tangible.example.com/auth/oidc/keycloak/callback.
TANGIBLE_OIDC_KEYCLOAK_DISPLAY_NAME=Keycloak
TANGIBLE_OIDC_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/myrealm
TANGIBLE_OIDC_KEYCLOAK_CLIENT_ID=tangible
TANGIBLE_OIDC_KEYCLOAK_CLIENT_SECRET_FILE=/run/secrets/keycloak_secret
TANGIBLE_OIDC_KEYCLOAK_ADMIN_GROUPS=tangible-adminsKeycloak issues group claims as /group-name paths by default. The
admin-group check is a substring match, so tangible-admins matches
/tangible-admins.
Authelia acts as an OIDC provider from version 4.38+. Create a client
entry in configuration.yml and register the redirect URI
https://tangible.example.com/auth/oidc/authelia/callback.
# authelia configuration.yml excerpt
identity_providers:
oidc:
clients:
- id: tangible
description: Tangible
secret: '$pbkdf2-sha512$...' # bcrypt or pbkdf2 hash of the secret
redirect_uris:
- https://tangible.example.com/auth/oidc/authelia/callback
scopes: [openid, profile, email, groups]
grant_types: [authorization_code]TANGIBLE_OIDC_AUTHELIA_DISPLAY_NAME=Authelia
TANGIBLE_OIDC_AUTHELIA_ISSUER=https://authelia.example.com
TANGIBLE_OIDC_AUTHELIA_CLIENT_ID=tangible
TANGIBLE_OIDC_AUTHELIA_CLIENT_SECRET_FILE=/run/secrets/authelia_secret
TANGIBLE_OIDC_AUTHELIA_ADMIN_GROUPS=tangible-adminsFor households that use Google or GitHub accounts, set the redirect URI
in the provider's developer console to
https://tangible.example.com/auth/oidc/<provider>/callback.
# Google
TANGIBLE_OIDC_GOOGLE_DISPLAY_NAME=Google
TANGIBLE_OIDC_GOOGLE_ISSUER=https://accounts.google.com
TANGIBLE_OIDC_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
TANGIBLE_OIDC_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_secret
# GitHub
TANGIBLE_OIDC_GITHUB_DISPLAY_NAME=GitHub
TANGIBLE_OIDC_GITHUB_ISSUER=https://token.actions.githubusercontent.com
TANGIBLE_OIDC_GITHUB_CLIENT_ID=Ov23li...
TANGIBLE_OIDC_GITHUB_CLIENT_SECRET_FILE=/run/secrets/github_secretGitHub does not issue an email scope by default — enable it in the
OAuth app settings or users will need to set a local email after first
login.
See the Configuration reference for every per-provider setting.
Most self-hosters already run a reverse proxy. Tangible sits behind it on port 8000. All proxies must:
- Forward the real client IP via
X-Forwarded-For. - Pass
Host,X-Forwarded-Proto, andX-Real-IPheaders. - Terminate TLS (Tangible does not manage certificates).
Set these regardless of which proxy you use:
TANGIBLE_PUBLIC_URL=https://tangible.example.com
TANGIBLE_BEHIND_PROXY=true
TANGIBLE_ALLOWED_HOSTS=tangible.example.comWithout TANGIBLE_BEHIND_PROXY=true, Tangible ignores forwarded-IP headers
and will rate-limit your proxy's IP instead of the real clients'.
Without TANGIBLE_ALLOWED_HOSTS, the host-header allowlist will reject
requests for your domain with HTTP 400.
Caddy obtains and renews TLS automatically via Let's Encrypt or ZeroSSL.
tangible.example.com {
encode zstd gzip
reverse_proxy tangible:8000
}Place this in your Caddyfile (global or per-site). Tangible and Caddy
must share a Docker network. A full reference snippet is also included at
docker/Caddyfile.example.
If you need the WebSocket upgrade header for future SSE support:
tangible.example.com {
encode zstd gzip
reverse_proxy tangible:8000 {
header_up Connection {>Connection}
header_up Upgrade {>Upgrade}
}
}Add Traefik labels to the Tangible service in your Compose file. This
assumes a Traefik instance already running with a proxy external
network and an https entrypoint:
services:
tangible:
image: ghcr.io/bradbrownjr/tangible:0
networks:
- proxy
- default
labels:
- "traefik.enable=true"
- "traefik.http.routers.tangible.rule=Host(`tangible.example.com`)"
- "traefik.http.routers.tangible.entrypoints=https"
- "traefik.http.routers.tangible.tls.certresolver=letsencrypt"
- "traefik.http.services.tangible.loadbalancer.server.port=8000"
environment:
TANGIBLE_PUBLIC_URL: https://tangible.example.com
TANGIBLE_BEHIND_PROXY: "true"
TANGIBLE_ALLOWED_HOSTS: tangible.example.com
networks:
proxy:
external: trueIf you use Traefik's forwardAuth middleware (e.g. pointed at
Authentik or Authelia), Tangible's own OIDC auth still applies — the two
layers are independent. Using the Traefik forwardAuth + TANGIBLE_OIDC_*
together lets Traefik enforce network-level auth while Tangible manages
collection ACL and sessions inside.
server {
listen 443 ssl;
server_name tangible.example.com;
ssl_certificate /etc/letsencrypt/live/tangible.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tangible.example.com/privkey.pem;
client_max_body_size 50M; # match TANGIBLE_DOCUMENTS_MAX_BYTES
location / {
proxy_pass http://tangible:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name tangible.example.com;
return 301 https://$host$request_uri;
}Pair with Certbot or acme.sh for certificate management.
The /data volume holds your content and the /config volume holds
secrets. A complete backup is both volumes captured at a consistent
point in time.
Quick, portable, no photos/documents:
docker exec tangible tangible backup alice - > alice-2026-04-29.jsonRestore into the same or a different deployment:
docker exec -i tangible tangible restore alice - < alice-2026-04-29.jsonFor full fidelity (photos, documents, sync state), snapshot the volumes:
# Stop the container so SQLite is consistent (skip if using Postgres).
docker compose stop tangible
tar czf tangible-data-$(date +%F).tgz \
-C /var/lib/docker/volumes/tangible_data/_data . \
-C /var/lib/docker/volumes/tangible_config/_data .
docker compose start tangibleOr use restic with hooks. With Postgres,
back up the database with pg_dump and the /data volume (which
still holds photos and documents).
| Path | Why |
|---|---|
/data/tangible.db (+ -wal, -shm) |
SQLite database |
/data/photos/ |
Item photos (content-addressed) |
/data/documents/ |
Item attachments (content-addressed) |
/config/secret.key |
Session & cookie signing key. Lose it → all sessions invalid; logged-in users sign back in. |
/config/tangible.yaml / tangible.env |
Optional declarative config |
docker compose pull
docker compose up -dThe container runs alembic upgrade head on start unless
TANGIBLE_DB_AUTO_MIGRATE=false. Always:
- Take a backup first (see above).
- Read the relevant section of
CHANGELOG.md. - Pin to a major or minor tag (
:0.11,:0) in production rather than:latest, so you don't pull breaking changes inadvertently. - After upgrades, review the shipped documentation set:
CHANGELOG.mdfor release notes,docs/user-guide.mdfor web/server user workflows, anddocs/android-user-guide.mdfor Android workflows.
The following recent features are client/API behavior only and do not require new environment variables or reverse-proxy changes:
- Web duplicate-item action.
- Web + API item review flags (
POST /items/{id}/flag,DELETE /items/{id}/flag) with automatic flag clear on the next standard item edit. - Dynamic template dropdown sources (
select_source: "dynamic") plusGET /collections/{collection_id}/template-field-options/{field_key}for deriving options from values already used across a collection. - Multi-value template fields (
type: "multi_value") for ordered lists, with server-side coercion from either JSON arrays or comma-separated text. - Relation template fields (
type: "relation") with scope control (same_collection/any_collection) and server-side target-item validation. - Community scraper registry discovery/import endpoints:
GET /metadata/registryandPOST /metadata/registry/import. Admins can pin trust status viaPATCH /metadata/registry/{entry_id}/trust. - Wanted-item tracking (
wantedboolean on items) plusGET /items?wanted=true/falsefiltering and inline Wanted/Owned toggles in web with acquisition-date / purchase-price capture when marking owned. - Bulk item actions via
POST /items/bulk-patch,POST /items/bulk-archive,POST /items/bulk-restore, andPOST /items/bulk-delete, with collection-scoped item-id validation and editor/owner role enforcement. - CSV hierarchy round-trip via
GET /imports/csv/exportplus CSV import mapping targetscategory_slug,ref:item_ref, andref:parent_refto preserve parent/child links. - Archive/disposition endpoints
POST /items/{id}/archiveandPOST /items/{id}/restore, withGET /items?include_archived=trueandarchived=true/falsefiltering for archive views. - Expanded item search across title/subtitle/notes/custom fields/identifiers,
plus web
/keyboard focus shortcut for collection search. - Web item sort controls (title, value, acquisition date, custom field).
- Web + Android barcode scanning from still images.
- Parent/container value rollups exposed as
rollup_current_valueon item reads.
To pin a specific version:
services:
tangible:
image: ghcr.io/bradbrownjr/tangible:0.11.0If a migration goes badly:
- Restore the volume snapshot taken before upgrade.
- Re-deploy the previous image tag.
- File a bug with the offending version +
docker logs tangibleoutput.
| Endpoint | Purpose |
|---|---|
GET /healthz |
Liveness/readiness probe. Used by the container HEALTHCHECK. |
GET /readyz |
Same, with a database round-trip. |
GET /version |
{"version": "X.Y.Z", "git_sha": "..."}. |
GET /metrics |
Prometheus metrics: tangible_http_requests_total, latency histograms, sync queue depth. |
Logs use structlog. For machine-parseable output:
TANGIBLE_LOG_FORMAT=json
TANGIBLE_LOG_LEVEL=INFOThe access-log middleware emits one structured line per request with
method, path, route, status, duration_ms, user_id, and
request_id.
Owner-visible per collection; admin-visible globally from
Admin → Audit log. Records member changes, invitations, share-
link creation/use, role changes, and bulk imports. Retained
indefinitely; prune with DELETE FROM audit_log_entries WHERE created_at < ... if you need to.
Tangible exposes a Model Context Protocol
endpoint at /mcp. AI clients that support MCP can query collections,
search items, and check maintenance/stock status in natural language.
No extra configuration is required — the endpoint is active by default.
To connect an AI client:
- The user generates a Bearer API token from Settings → Tokens.
- The client is configured with the server URL (
https://tangible.example.com/mcp) andAuthorization: Bearer <token>.
Available MCP tools:
| Tool | What it returns |
|---|---|
list_collections |
All collections the token owner can see |
search_items |
Items matching a title/keyword query |
get_item |
Full detail of one item by ID |
list_maintenance |
Upcoming and overdue maintenance tasks |
list_due_alerts |
Items/docs with expiry within a window |
list_low_stock |
Items whose quantity is at or below minimum |
To verify the endpoint is running:
curl -H "Authorization: Bearer $TOKEN" https://tangible.example.com/mcpThe endpoint uses the same rate limits as the standard API
(TANGIBLE_RATE_LIMIT_API). The path /mcp can be blocked at the reverse-
proxy layer if you do not want AI access.
| Variable | Default | Notes |
|---|---|---|
TANGIBLE_RATE_LIMIT_LOGIN |
5/minute |
Per-IP, login + register |
TANGIBLE_RATE_LIMIT_API |
120/minute |
Per-token (or per-IP for unauth) |
Rate-limit hits return 429 Too Many Requests with a
Retry-After header.
Reject huge uploads early to protect the disk:
| Variable | Default |
|---|---|
TANGIBLE_PHOTOS_MAX_BYTES |
26214400 (25 MiB) |
TANGIBLE_DOCUMENTS_MAX_BYTES |
52428800 (50 MiB) |
TANGIBLE_PHOTOS_DIR |
${TANGIBLE_DATA_DIR}/photos |
TANGIBLE_DOCUMENTS_DIR |
${TANGIBLE_DATA_DIR}/documents |
Both stores are content-addressed: identical bytes uploaded twice take one slot on disk. Deleting the last reference garbage-collects the file.
Attachment search indexes document text. PDF/text extraction works out
of the box; image OCR indexing requires the tesseract binary to be
installed in the runtime environment.
After bulk imports / large deletes:
docker exec tangible sqlite3 /data/tangible.db "VACUUM;"Replace /config/secret.key and restart. All sessions are invalidated;
API tokens continue to work.
docker exec tangible tangible migrate| Symptom | Likely cause / fix |
|---|---|
| Container restarts in a loop | docker logs tangible — usually a bad TANGIBLE_* value or migration failure. |
| 400 Bad Request on every request | TANGIBLE_ALLOWED_HOSTS doesn't include the host you're hitting. Loopback (localhost, 127.0.0.1, ::1) is always allowed. |
| OIDC callback returns "invalid state" | Cookies blocked by reverse-proxy stripping Set-Cookie, or TANGIBLE_FORCE_HTTPS mismatch. |
Permission denied writing /data |
PUID/PGID don't match the host owner of the volume. Adjust env vars or chown the volume. |
429 storms during import |
Bulk operations should use the CLI, not the API. Or raise TANGIBLE_RATE_LIMIT_API. |
| OIDC button missing | Provider env vars not set or TANGIBLE_OIDC_ENABLED=false. Check `docker exec tangible env |
| Sync conflicts on mobile | Open the item; Tangible shows both versions and lets you pick. CRDT keeps history forever in automerge_changes. |
- File issues at https://github.com/bradbrownjr/tangible/issues.
- Include
docker exec tangible tangible version, the relevant section ofdocker logs tangible, and your sanitizedtangible.env.