A self-hostable data catalog that integrates with DVC.
It stores dataset metadata and .dvc pointer files (never the data itself),
adds descriptions, tags, and access scopes, and lets teams discover
datasets and pull them with plain dvc pull.
The catalog is the "index"; DVC + your existing remote (S3, Azure Blob, GCS,
MinIO, SSH, …) remain the storage. A companion CLI, dvc-catalog, wraps
dvc push/dvc pull so a single command publishes data to your remote and
registers its metadata + .dvc snapshot in the catalog.
License: Apache-2.0 · Status: planning (see
TODO.md) · Quality gate: localpre-commit; manual release automation only (seeAGENTS.md).
- What it does
- Core concepts & domain model
- High-level architecture
- Request/data flows
- Technology stack
- Repository structure
- Authentication & authorization
- Search
- Versioning model
- The
dvc-catalogCLI - Deployment
- Observability
- Local development
- Configuration reference
- Security & engineering practices
- Publish once, land twice.
dvc-catalog pushrunsdvc pushto your DVC remote, then uploads the resulting.dvcfile(s), metadata, and the used project-level remote configuration to the catalog. - Describe & classify. Every dataset carries a description, tags, an owner, license, size/file-count, source Git repo + commit, and admin-definable custom key/value fields.
- Govern access. Access is granted via groups and per-dataset scopes
(
read/write/admin). Groups can be synced from your IdP. Users can request access; owners/admins approve. - Discover. Combined full-text (name, description, README) and faceted search (tags, project, scope, custom fields), served by OpenSearch.
- Consume. On a dataset page, copy the
.dvcto clipboard, download it, or copy advc-catalog get <id>command — thendvc pullas usual. - Stay in sync with Git. Every published version records the Git commit SHA captured by the CLI, so a dataset version always points back at its source revision.
- Deploy anywhere. First-class Docker images, a
docker composestack for local/dev, and a Helm chart (Gateway API, not Ingress) for Kubernetes.
Organization
└── Project (maps 1:1 to a Git repository)
└── Dataset (a tracked data artifact; has metadata + scopes)
└── Version (immutable snapshot; content-hash + git commit SHA)
└── DvcArtifact (the .dvc file content + parsed outs/hashes)
Identity & access:
User ── membership ──> Group ── grant ──> Scope(read|write|admin) on Dataset/Project
AccessRequest (user -> dataset, pending|approved|denied)
PersonalAccessToken (user -> API token for CLI/CI)
Cross-cutting:
Tag, CustomFieldDefinition / CustomFieldValue
AuditEvent (who did what, when, to which entity)
Notification, NotificationChannel
| Entity | Purpose |
|---|---|
Organization |
Top-level tenant boundary. |
Project |
Bound 1:1 to a Git repo; owns datasets; sets default dataset visibility. |
Dataset |
Logical data artifact; holds metadata, tags, scopes, custom fields. |
Version |
Immutable snapshot created on each push; stores content/config hash + Git commit SHA + lineage + used DVC remote configuration. |
DvcArtifact |
The raw .dvc file text plus parsed md5/outs/size for that version. |
Group / Scope |
Authorization: groups hold members; scopes grant read/write/admin. |
AccessRequest |
Self-service access workflow with approval. |
AuditEvent |
Append-only record of every mutating action. |
┌───────────────────────┐ ┌────────────────┐
│ Web Browser │ │ dvc-catalog │
│ React + Vite + shadcn │ │ CLI/CI │
└───────────┬───────────┘ └───┬────────┬───┘
│ HTTPS (SPA + REST/OpenAPI) │ │
▼ │ │ dvc push/pull
┌──────────────────── Gateway API ─────────────▼──┐ │ (never via
│ (K8s Gateway / Traefik in compose, TLS) │ │ the catalog)
└───────────────────────┬────────────────────────────┘ │
│ REST + PAT │
┌───────────▼────────────┐ │
│ FastAPI API │ │
│ (uvicorn/gunicorn) │ │
│ REST + built SPA on │ │
│ a single port │ │
└──┬─────────┬────────┬──┘ │
auth │ │ enqueue│ read/write │
┌────────────────▼──┐ ┌───▼────┐ ┌▼─────────────┐ ┌───────▼────────┐
│ Auth backends │ │ Redis │ │ PostgreSQL │ │ DVC Remote │
│ OIDC/SAML/local │ │ broker │ │ (source of │ │ S3/Azure/GCS/ │
└───────────────────┘ └───┬────┘ │ truth) │ │ MinIO/SSH ... │
│ └──────┬───────┘ └────────────────┘
┌──────▼───────┐ │ index from
│ Celery │◀─────┘
│ workers │
│ (indexing, │
│ notify) │
└──────┬───────┘
│ index / query
┌──────▼───────┐
│ OpenSearch │
│ (search) │
└──────────────┘
Observability: OpenTelemetry traces + Prometheus metrics + structured JSON logs
→ Grafana / Tempo / Loki (dashboards shipped in the Helm chart).
- FastAPI API — REST endpoints, OpenAPI schema, authn/authz, validation,
writes to Postgres, enqueues Celery jobs, serves signed
.dvcdownloads, and serves the built frontend SPA on the same port (no separate web server). - Celery workers — (re)index datasets into OpenSearch, deliver notifications, run periodic maintenance jobs.
- PostgreSQL — system of record for all metadata,
.dvctext, versions, ACLs, audit log. - OpenSearch — denormalized search index (full-text + facets); rebuildable from Postgres at any time.
- Redis — Celery broker/result backend and short-lived caches.
- Frontend — SPA for browsing, searching, dataset detail, admin, access requests. Built to static assets and served by the FastAPI app itself, so the UI and the API share a single origin and port.
dvc-catalogCLI — client-side wrapper around DVC + catalog REST API.
- CLI validates auth (PAT or OIDC device flow) and resolves the project from
.dvc/catalog(the committed catalog connection config). - CLI runs
dvc pushto the configured DVC remote (data leaves the client directly to the remote — never through the catalog). - CLI reads the
.dvcfile(s), their.dvc.metasidecars (description, owner, tags, license, custom fields), and only the used remote sections from project-level.dvc/config. It never reads.dvc/config.local, global, or system DVC configuration. - CLI computes a hash over the pointer files and canonical remote snapshot,
captures the current Git commit SHA, and
POSTs the.dvctext, metadata, and storage configuration to/api/v1/datasets/{id}/versions. A push is refused if the catalog is not configured or a selected sidecar is incomplete; a remote available only outside project config is registered as unavailable rather than blocking publication. - API persists an immutable
Version+DvcArtifact, writes anAuditEvent, enqueues an OpenSearch index job and notifications.
- User searches/browses in the SPA (queries hit OpenSearch via the API).
- On the dataset page the user reviews the published DVC location/setup steps,
copies the
.dvcto clipboard, downloads it, or copiesdvc-catalog get <dataset>@<version>. - User drops the
.dvcinto their repo (or runsdvc-catalog get) and runsdvc pull, which fetches data straight from the DVC remote.
| Layer | Choice |
|---|---|
| Backend | Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0, Alembic |
| Async/jobs | Celery + Redis |
| Database | PostgreSQL (source of truth) |
| Search | OpenSearch (full-text + faceted) |
| Frontend | React + Vite + TypeScript, shadcn/ui + Tailwind, TanStack Query + Router |
| CLI | Python (Typer), wraps the dvc package |
| Auth | Pluggable: OIDC/Keycloak, SAML, local email+password; PATs for machines |
| Packaging | uv (backend/CLI) · pnpm (frontend) |
| Deploy | Docker, docker compose, Helm (Gateway API) |
| Observability | OpenTelemetry, Prometheus, structured logs, Grafana dashboards |
| Quality | pre-commit (ruff, mypy, bandit, detect-secrets, gitleaks, …), pytest, Vitest, Playwright |
dvc-datacatalog/
├── AGENTS.md # Mandatory working rules (pre-commit, no ignores)
├── README.md # This file
├── TODO.md # Detailed 10-task delivery plan
├── LICENSE # Apache License 2.0
├── NOTICE # Project and DVC attribution notices
├── .pre-commit-config.yaml # The local development quality gate
├── .github/workflows/release.yaml # Manual, independently published releases
├── .secrets.baseline # detect-secrets baseline (reviewed)
├── pyproject.toml # Shared Python tool config (ruff/mypy/bandit/codespell)
│
├── backend/ # FastAPI service
│ ├── app/
│ │ ├── main.py # App factory, router mounting, OTel setup
│ │ ├── api/v1/ # Routers: datasets, versions, search, auth, admin
│ │ ├── core/ # Settings, security, logging, dependencies
│ │ ├── auth/ # Pluggable providers: oidc/, saml/, local/, pat/
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic request/response models
│ │ ├── services/ # Domain logic (catalog, acl, search, notify)
│ │ ├── search/ # OpenSearch client, mappings, indexers
│ │ ├── tasks/ # Celery app + task modules
│ │ └── db/ # Session, base, seed
│ ├── alembic/ # Migrations
│ ├── tests/ # pytest (unit + integration)
│ ├── Dockerfile
│ └── pyproject.toml # uv-managed deps
│
├── frontend/ # React + Vite SPA (built into the API image)
│ ├── src/
│ │ ├── app/ # Router, providers, layout
│ │ ├── components/ui/ # shadcn/ui primitives (generated)
│ │ ├── features/ # search, datasets, projects, admin, auth, access-requests
│ │ ├── lib/ # API client (generated from OpenAPI), hooks, utils
│ │ └── styles/ # Tailwind
│ ├── tests/ # Vitest
│ ├── e2e/ # Playwright
│ └── package.json # pnpm
│
├── cli/ # `dvc-catalog`
│ ├── dvc_catalog/
│ │ ├── __main__.py # Typer entrypoint
│ │ ├── commands/ # init, login, whoami, status, push, get, pull
│ │ ├── repo.py # Locate .dvc repo; discover .dvc files
│ │ ├── config.py # Read/write .dvc/catalog + .dvc/catalog.local
│ │ ├── metadata.py # Load/validate .dvc.meta sidecars
│ │ ├── api_client.py # REST client + PAT/OIDC auth
│ │ └── dvc_bridge.py # Thin wrapper over the dvc CLI
│ ├── tests/
│ └── pyproject.toml # uv; exposes `dvc-catalog` console script
│
├── deploy/
│ ├── compose/ # docker-compose.yml + overrides + Traefik(Gateway)
│ ├── helm/dvc-datacatalog/ # Chart: api (serves SPA), worker, gateway, HPA, dashboards
│ └── images/ # Shared base images / entrypoints
│
└── docs/ # Architecture, ADRs, runbooks, API guides
Authentication is pluggable and chosen at deploy time via configuration. Supported providers (one or more can be enabled simultaneously):
- OIDC / Keycloak — Authorization Code + PKCE for the SPA; device flow for the CLI; IdP groups mapped to catalog groups.
- SAML 2.0 — for enterprises standardized on SAML SSO.
- Local email + password — self-contained, with Argon2 hashing and TOTP MFA.
- Personal Access Tokens (PATs) — for the CLI and CI; scoped, expiring, revocable.
Authorization model: groups + per-dataset scopes (read / write /
admin), grantable to users or groups. When the IdP supplies groups, scopes can
be synced automatically. Default dataset visibility is configurable per
project. A self-service access-request workflow lets a user request
read/write; owners/admins approve, which auto-grants the scope.
OpenSearch powers a combined experience:
- Full-text over name, description, and README with ranking and highlighting.
- Faceted filters over tags, project, organization, scope/visibility, license, and custom fields.
- Autocomplete/typeahead for tags and dataset names.
Postgres remains the source of truth; the index is fully rebuildable via a Celery reindex task. Access filtering is applied so results respect the caller's scopes.
- Each
dvc-catalog pushcreates an immutableVersionidentified by the content hash of the.dvcoutputs and the location-bearing subset of its canonical project-level remote snapshot (remote names and URLs). Moving unchanged objects to another published remote URL therefore records a new version; changing a tuning knob such asjobsdoes not. - Every version also stores the Git commit SHA (as metadata) plus basic lineage (source repo/commit, produced-by pipeline stage when available).
- Full history is browsable; you can fetch any historical
.dvcanddvc pullthat exact snapshot.
A standalone wrapper (no fork of DVC required) that is configured the DVC way:
all catalog settings live under the repo's .dvc/ directory, and dataset
metadata lives in a YAML sidecar next to each .dvc file. There are
deliberately no metadata flags — publishing is driven entirely by committed
config, so a push is reproducible and reviewable.
dvc-catalog init # scan repo for .dvc files, scaffold config + sidecars
dvc-catalog login # store a PAT in .dvc/catalog.local (gitignored)
dvc-catalog whoami # show configured url / project / auth state
dvc-catalog status # list every .dvc and whether its sidecar is complete
dvc-catalog push [targets] # validate, run `dvc push`, then register metadata
dvc-catalog get <dataset>[@<version>] # writes the .dvc into your project
dvc-catalog pull <dataset> # get + dvc pull in one step| File | Committed? | Purpose |
|---|---|---|
.dvc/catalog |
yes (INI) | Catalog connection: [catalog] url + project. |
.dvc/catalog.local |
no (gitignored) | Secrets: [auth] token. Written by login. |
<path>.dvc.meta |
yes (YAML) | Per-dataset metadata next to each .dvc file. |
The token is kept in a dedicated .dvc/catalog.local (added to .dvc/.gitignore)
rather than DVC's own config.local, so it never trips DVC's config validation.
A metadata sidecar looks like this (data/images.dvc → data/images.dvc.meta):
description: "Curated training images" # required
owner: "team-vision" # required
tags: [images, cv]
license: MIT
custom_fields:
sensitivity: low- Init first.
pushfails if.dvc/cataloghas nourl/projector no token is stored — rundvc-catalog initanddvc-catalog loginfirst. - Multiple
.dvcfiles are first-class. With no targets, every.dvcin the repo is selected (mirroringdvc push); pass targets to restrict the set. - Metadata is required. Every selected
.dvcmust have a complete sidecar (description+owner); otherwise the push aborts and lists the offenders. Usedvc-catalog statusto check readiness and--dry-runto validate without pushing.
The CLI never uploads data to the catalog — only .dvc text + metadata.
All three paths are first-class:
- Docker — multi-stage images for
api(which bundles the built SPA),worker, andcli. - docker compose — one-command local stack: api, worker, Postgres, Redis, OpenSearch, and a Traefik Gateway with TLS.
- Helm (
deploy/helm/dvc-datacatalog) — Deployments for api/worker, Kubernetes Gateway API resources (Gateway+HTTPRoute, not Ingress), HPAs, PodDisruptionBudgets,values.yamltoggles for each auth backend, external vs bundled Postgres/Redis/OpenSearch, and Grafana dashboards.
TLS is terminated at the Gateway in every environment. The SPA and the API are served by the same FastAPI process on a single port, so no separate web server (nginx or otherwise) is deployed.
- Traces: OpenTelemetry across API and Celery, exported via OTLP (Tempo/ Jaeger compatible).
- Metrics: Prometheus (
/metrics) for API traffic and process health. - Logs: structured JSON with correlation/trace IDs (Loki-friendly).
- Dashboards: Grafana dashboards shipped in the Helm chart.
# 1. Clone and install the quality gate (mandatory — see AGENTS.md)
pip install pre-commit && pre-commit install --install-hooks
# 2. Backend
cd backend && uv sync && uv run alembic upgrade head
uv run uvicorn app.main:app --reload
# 3. Frontend (dev server with HMR, proxying API paths to the backend)
cd frontend && pnpm install && pnpm dev
# For a production-like run, `pnpm build`, then start the backend with
# APP_FRONTEND_DIST=../frontend/dist so it serves the SPA and the API
# from the same port.
# 4. Full stack
docker compose -f deploy/compose/compose.yaml up --build
# 5. Before every push
pre-commit run --all-filesThere is no automatic push or pull-request CI. pre-commit remains the local quality gate.
The manually dispatched release workflow repeats it before publishing artifacts.
Configuration is environment-variable driven (12-factor). Every setting is read
from APP_<FIELD> — APP_DB_DSN, APP_AUTH_ENABLED_PROVIDERS, and so on. For
the deployment groups below the unprefixed spelling (DB_DSN,
AUTH_ENABLED_PROVIDERS, …) is accepted as a fallback; when both are set the
APP_-prefixed one wins.
| Prefix | Purpose |
|---|---|
APP_ |
App name, base URL, environment, log level |
APP_FRONTEND_DIST |
Directory of the built SPA to serve on the API port; unset = API-only |
APP_DB_ / DB_ |
PostgreSQL DSN, pool sizing |
APP_REDIS_ / REDIS_ |
Broker/result URL |
APP_SEARCH_ / SEARCH_ |
OpenSearch host, index prefix |
APP_AUTH_ / AUTH_ |
Enabled providers + per-provider settings (AUTH_OIDC_*, AUTH_SAML_*, AUTH_LOCAL_*) |
APP_MAIL_ / NOTIFY_ |
SMTP host, port, credentials, envelope sender |
APP_OTEL_ / OTEL_ |
OTLP endpoint, sampling |
APP_AUTH_ENABLED_PROVIDERS accepts either spelling:
APP_AUTH_ENABLED_PROVIDERS=local,oidc or '["local","oidc"]'.
Redis-backed credential throttling and Celery indexing are opt-in, so
upgrading an existing deployment does not silently acquire a new hard
dependency: with APP_REDIS_RATE_LIMIT_ENABLED on and Redis unreachable, every
credential endpoint answers 503 and authentication stops working entirely.
Production refuses to start without both. APP_ENVIRONMENT=production requires
APP_REDIS_RATE_LIMIT_ENABLED=true and APP_CELERY_ENABLED=true, and names the
missing variable in the startup error rather than leaving an operator to
discover a per-worker rate limit or a permanently stale search index in
production. Provision Redis and OpenSearch, and run a worker and beat, before
enabling them.
| Variable | Default | Why it matters |
|---|---|---|
APP_AUTH_LOCAL_REGISTRATION_ENABLED |
false |
Self-registration is opt-in. Enabling it requires APP_AUTH_DEFAULT_ORGANIZATION_SLUG. |
APP_AUTH_DEFAULT_ORGANIZATION_SLUG |
(unset) | The tenant new accounts join. Membership is never inferred from row order. |
APP_AUTH_RESET_DELIVERY |
log |
log prints the reset token (development only); production requires smtp. |
APP_AUTH_COOKIE_SECURE |
true |
Production refuses to send session cookies over plain HTTP. |
APP_AUTH_MAX_FAILED_LOGINS |
5 |
Failed attempts before the account locks. |
APP_AUTH_LOCKOUT_SECONDS |
900 |
Base lockout interval; doubles for each further failure. |
APP_AUTH_RATE_LIMIT_MAX_ATTEMPTS |
30 |
Attempts permitted per sliding window. Shared across API workers only when APP_REDIS_RATE_LIMIT_ENABLED is on. |
APP_REDIS_BROKER_URL |
redis://localhost:6379/0 |
Celery broker used by API dispatchers, workers, and beat. |
APP_REDIS_RESULT_BACKEND |
redis://localhost:6379/1 |
Celery task result backend. |
APP_REDIS_RATE_LIMIT_URL |
redis://localhost:6379/2 |
Shared credential-throttling state. |
APP_REDIS_RATE_LIMIT_ENABLED |
false |
Requires Redis. Shares the credential throttle across API workers; without it each worker counts separately. Required in production. |
APP_CELERY_ENABLED |
false |
Requires Redis. Publishes post-commit catalog index events to Celery; without it the search index is never updated. Required in production. |
APP_CELERY_CONSISTENCY_INTERVAL_SECONDS |
900 |
Beat interval for fingerprint-based PostgreSQL/OpenSearch drift repair. |
APP_SEARCH_URL |
http://localhost:9200 |
OpenSearch endpoint used by API and worker processes. |
APP_SEARCH_INDEX_PREFIX |
dvc-catalog |
Prefix for concrete dataset indexes and the live alias. |
APP_AUTH_SESSION_RETENTION_DAYS |
90 |
Days to retain revoked or long-expired session metadata before pruning. |
APP_AUTH_PASSWORD_RESET_RETENTION_DAYS |
30 |
Days to retain consumed or long-expired reset-token hashes before pruning. |
APP_TRUST_PROXY_HEADERS |
false |
Enable only behind a proxy that always sets X-Forwarded-For, so the audit trail records the client and not the gateway. |
APP_SEED_ADMIN_PASSWORD |
(unset) | Password for a newly seeded admin. Required when seeding a production database. |
The dvc-catalog CLI is configured per-repository instead of via env vars:
.dvc/catalog (committed: url, project), .dvc/catalog.local (gitignored:
token), and a <path>.dvc.meta YAML sidecar per dataset. See
The dvc-catalog CLI.
- Local quality gate, no automatic CI. Every commit must pass
pre-commit run --all-files; the manual release workflow repeats the gate before publication (seeAGENTS.md). - No inline suppressions of any kind (
# type: ignore,# noqa,# nosec,eslint-disable,@ts-ignore, secret allow-lists, …). Fix the root cause. - Secrets scanned by detect-secrets + gitleaks; never commit real secrets.
- Typed & tested: mypy
--strict, pytest (backend), Vitest + Playwright (frontend). - Data never in Git: large files blocked by pre-commit; only
.dvc+ metadata are tracked.
DVC Data Catalog is licensed under the
Apache License, Version 2.0. It integrates with
DVC (Data Version Control), an independent project
maintained by Treeverse and also distributed
under Apache-2.0. See NOTICE for the DVC attribution and upstream
license reference.
See TODO.md for the detailed, step-by-step delivery plan.