CarTracker GitHub →

A production data pipeline for tracking car prices

CarTracker started as a personal project — I was car shopping and wanted a data-driven way to spot a good deal before it disappeared. What began as a simple scraper grew into a production pipeline: continuous collection from Cars.com, a lakehouse built on Postgres, MinIO, and DuckDB, and a deal-score model that turns a long history of price observations into a single number. All of it runs on a single host, which is the constraint most of the interesting decisions here answer to. It is as much a learning project as a portfolio piece — every architectural decision was made, reconsidered, and sometimes replaced as the system grew into something that actually had to work.

Live stats

52K
Active listings
2.5K
Pages fetched / hr
11K
Observations processed / hr
14.0M
Total price observations
14
Make/model pairs tracked
Analytics data through

Every number above comes from the analytics marts, as of the timestamp shown. The marts refresh hourly, so these are recent rather than live. Counts describing the repository itself — services, DAGs, models, tests — are deliberately rounded on this page, because an exact figure in hand-written copy is a number nobody updates.

How data flows

CarTracker data flow, from fetch to dashboard The scraper fetches a page from Cars.com and writes it two places at once: the compressed HTML to MinIO as bronze, and an artifact pointer with its claim to Postgres. Processing reads the stored HTML and again writes two places in a single Postgres transaction: the current row per entity in the ops schema, and the matching event into an append-only staging buffer. Those two writes begin two separate paths. The current state feeds the scrape queue view, which decides what the scraper fetches next, closing a control loop. The event buffer is exported by the archiver to partitioned Parquet on MinIO, which dbt and DuckDB build into mart tables, which the dashboard and this page read. archiver exports, then deletes what to fetch next Cars.com FETCH Scraper BRONZE Compressed HTML immutable object, MinIO OPERATIONAL Artifact pointer + claim Postgres ops PARSE Processing OPERATIONAL HOT state one current row per entity STAGING Event buffer append-only, Postgres staging both writes, one transaction DECIDES Scrape queue a Postgres view, read in place SILVER Parquet history partitioned by source and date MART dbt + DuckDB marts SERVING Dashboard, and this page

The two forks are the whole design. The scraper writes the page and its pointer separately; processing writes current state and its event in one transaction, and those two writes begin two paths that never rejoin. The left path is a loop — current state decides what gets fetched next, in a Postgres view read transactionally by the same query that claims the work. The right path is a record, and nothing on it can block a fetch.

What lives where, and at what grain

Layer Physical home Grain Why it exists
Bronze MinIO One compressed HTML object per fetched results or detail page Preserves exactly what the site returned, so parser changes are replayable
Operational Postgres ops One current row per artifact, listing, VIN mapping, claim, or cooldown Low-latency point lookups, transactions, leases, and conflict handling
Staging buffers Postgres staging Append-only mutations and typed observations awaiting bulk export Decouples small transactional writes from columnar object-store writes
Silver MinIO (Parquet) One typed observation per listing appearance, partitioned by source and date Durable analytical history, cheap and scan-friendly
Mart DuckDB, built by dbt VIN-, listing-, hour-, cohort-, and benchmark-grain products Turns history into dashboard-ready business meaning

The division that makes the rest of it work: Postgres owns what the pipeline needs right now; Parquet owns what happened. Current state is small, mutable, and read by key. History is append-only, columnar, and read in scans. Serving both from one store is the design this pipeline started with, and replaced.

Production architecture

The platform runs as more than two dozen long-running services on a single Compose host, alongside a few profile-gated and one-shot ones. These are the parts worth explaining. Click any card for the reasoning behind it.

Scraper FastAPI Why?

Fetches results and detail pages on a rotation across the tracked make/model pairs, and stores the compressed HTML without parsing it.

SLV
Solver sidecar Browser Why?

Performs a browser-assisted session bootstrap for pages that require one, and hands the resulting session back to the scraper over a small HTTP API.

Processing FastAPI Why?

Turns stored HTML into structured observations, separating what the pipeline needs right now from what long-term analytics needs eventually.

Archiver FastAPI Why?

Moves the permanent record out of Postgres — exporting the staging event buffers to partitioned Parquet on object storage, then deleting the rows it exported.

PACK
Pack worker Packing Why?

Packs closed months of bronze HTML into immutable packs with a columnar index, and prunes the sources only after verifying the packed copy reads back identically.

dbt Runner DuckDB Why?

The analytical layer — 20+ dbt models across staging, intermediate, and mart layers, built by DuckDB reading Parquet straight from object storage.

Airflow Airflow Why?

Sequences the whole pipeline — fetching, processing, archival, maintenance, and analytics — across services that have no direct knowledge of each other.

Ops FastAPI Why?

The control plane — authorization, configuration, claim coordination, deploy sequencing, and the page you are reading right now.

Dashboard Streamlit Why?

The payoff — price history, deal scores, inventory coverage, and pipeline health, read from the DuckDB marts. Access-gated; the live stats above are its public slice.

Postgres State Why?

Holds current operational state and the short-lived event buffers beside it — and, deliberately, nothing else.

MinIO S3 Why?

The storage backbone — replayable bronze HTML and the permanent Parquet history, in a format built for the queries that read it.

Caddy + oauth2-proxy Edge Why?

The front door — TLS, Google authentication, and role enforcement, so every service behind it can be written as though authorization were somebody else's problem.

Observability Monitoring Why?

Metrics, logs, three-state service health, and alerting — designed around the failure this project actually had, which was silence rather than errors.

Platform evolution

Migration track — not serving users

Everything above is running in production today. The work below is proven and evidenced, and none of it is in the path of anything a visitor to this site sees. It is listed separately because a portfolio that blurs the two is telling you about a roadmap while implying it is a system.

  • Iceberg via Lakekeeper, exercised through Spark. Tables register and read, and dbt-Spark parity work runs against them. The dashboard still reads DuckDB, and will until the migration's own gates close.
  • MLflow experiment provenance — tracking for the modelling work, not a serving path.
  • Adaptive-refresh and backtesting foundations — the groundwork for deciding what to re-fetch and when, based on how a listing has actually behaved.

The short version: the dashboard reads DuckDB. The Iceberg work is a migration track with its own gates, not a shipped capability.

Recent work

Work here is planned and archived in the open: every change is a plan document with a stated trigger and an explicit gate, ordered in one table. Two rules make that record worth reading — a merged change is not a finished one while its production gate is outstanding, and a plan reaches the archive only when its evidence does.

Planned next

Recently completed

For the long-form account there is a weekly recap, one page per week written against that week's commits — a point-in-time record, never revised to match a later truth.

Decisions worth explaining

Five choices that changed the shape of the system. Two of them started as something that broke.

Deploying without breaking what is already running

Restarting a container mid-pipeline means lost work, orphaned claims, and partial writes that are awkward to recover from. So a redeploy is a sequence of claims that must become true: request scoped coordination, drain until admitted work is gone, recreate only the requested services, wait for each to reach its real health state, validate, and only then release the gate. The two failure branches differ on purpose — if validation fails before any container changed, coordination is released, because the pipeline should not stay paused for a change that never happened. If a container was already replaced and a later step fails, coordination stays held, because a mixed-version fleet is a worse place to resume background work than a visible pause.

How it is tested

More than 3,000 tests run in CI. The count is the least interesting fact about them — the variety of boundaries is the point, because many of the hardest bugs here live between files or engines rather than inside a function.

Pure behavior

Parser rules, retry and backoff calculations, pack formats, metrics, state transitions, failure predicates. No database, no Docker, run first.

Configuration contracts

Compose healthcheck coverage, the expected service set, Prometheus jobs, Grafana selectors, log-shipping policy, deployment timeout relationships, planning-document invariants.

Real service integration

Migrated Postgres, object storage, DuckDB extensions, HTTP routers, Airflow DAG loading, archiver operations. No mocks where real behavior is what is in question.

Cross-engine equivalence

The same production-shaped lake fixture is evaluated by dbt and by independently written selector SQL, so an optimization cannot quietly change business meaning and pass as a speedup.

The fixture underneath that fourth layer is built with its arrow reversed. The usual approach takes some production rows and hopes they exercise the code. Here the branches come first: each dbt branch or guard worth protecting is named as a coverage selector, and the selector is a SQL query that goes and finds production entities exhibiting that behavior. There are more than twenty — gaps-and-islands state runs, price-drop and price-increase lags, source-priority ties where a detail row outranks a search-results row, sparse benchmark groups that must not silently disappear, each 403 cooldown bucket boundary. The selected entities are then closed over VINs, listings, and artifacts, so rows arrive with everything they need to join. What the snapshot asserts is coverage itself: a named behavior with no representative is a reported shortfall rather than a branch nobody noticed was untested.

A unit test for the health collector cannot prove every Compose service has a healthcheck, and a dbt model test cannot prove the archiver's separately maintained selector returns the same cohort. Those need repository-level assertions, which is why configuration and equivalence tests are first-class here rather than an afterthought. CI builds a miniature production data plane for every run: real migrations against a real Postgres, the same pinned adapters and extensions the runtime uses, a seeded lake fixture, a real dbt build, and then the integration suites against it. Schema changes are versioned migrations reviewed as pull requests — ALTER TABLE in production without a migration history is its own category of wrong, and this project does not allow it.