Build guides
Appraise a trade-in
Last updated:
Build Larkspur Motors' trade-in desk on Next.js and better-auth: a website estimate by VIN, a walk-around on the lot, and a desk sheet priced from comparables, the window sticker and the market. See the use case.

You'll build Larkspur Motors, a car dealership's app: an estimate online, a walk-around on the lot, a firm offer from the desk. See what it does.
- Time: About an hour, or 5 minutes to clone and run.
- You need: Node 20 or later and pnpm, A Cardog API key, Some Next.js
- Cardog SDK:
cardog.v2.vin.getByVin,cardog.v2.quotes.getByRef,cardog.v2.listings.search,cardog.v2.vin.windowSticker,cardog.v2.recalls.entity - Clone the finished app: cardog-ai/example-trade-in-appraisal, or build it step by step below.
git clone https://github.com/cardog-ai/example-trade-in-appraisal.git1. Get an API key
Every request to Cardog carries a key. Create one under Account → API, then copy .env.example to .env.local. The app reads the key on the server; the browser never sees it.
The example values run as shipped. Without a key the app runs on responses recorded for three VINs, so you can follow along before you sign up. DEALER_LAT, DEALER_LNG and DEALER_RADIUS_KM place the store, for comparables nearby.
BETTER_AUTH_SECRET ships as a development placeholder, so a fresh clone builds and runs. Replace it with your own (openssl rand -base64 32) before you deploy: a production server will not start without one, and warns on every start while the placeholder is set.
.env.example
# Copy to .env.local. With these values the app runs on recorded fixtures
# and a development auth secret.
# Your Cardog API key (https://cardog.app/account/api). Server-side only; it
# never reaches the browser. Leave empty for fixture mode.
CARDOG_API_KEY=
# Signs sessions. This value is a DEVELOPMENT PLACEHOLDER so a fresh clone
# builds and runs; the app warns on every production start while it is set.
# Replace it before deploying: openssl rand -base64 32
BETTER_AUTH_SECRET=larkspur-dev-only-secret-replace-before-deploying
# Where the app is served. Needed when it is not http://localhost:3022.
BETTER_AUTH_URL=http://localhost:3022
# SQLite file and the folder walk-around photos are saved in. Created on first run.
DATABASE_PATH=data/larkspur.db
PHOTOS_DIR=data/photos
# Where the store is, for comparable listings nearby. Defaults to the
# store's own lot; radius in kilometres.
DEALER_LAT=43.5448
DEALER_LNG=-80.2482
DEALER_RADIUS_KM=250
# Seeded review logins are listed in the README.2. Create the Next.js app
Larkspur is one Next.js app: the store's public estimate, the customer's account, the appraiser's phone screen and the desk. SQLite holds the appraisals and photos sit on disk, so there is nothing else to run. better-sqlite3 is a native module, so the config loads it at runtime.
terminal
pnpm create next-app@latest larkspur --ts --app --tailwind --src-dir
cd larkspur
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// better-sqlite3 is a native module: load it from node_modules at runtime.
serverExternalPackages: ["better-sqlite3"],
// This app is its own root, even when it sits inside another repository.
outputFileTracingRoot: process.cwd(),
};
export default nextConfig;3. Construct the Cardog client on the server
One file holds every Cardog call. The client is built once from the key, and the server-only import fails the build if a browser bundle ever reaches for it. Each call is timed and logged, which is what the app's "What the app called" pane shows.
Start with the two calls every appraisal makes. The decode returns the vehicle and the refs every other call takes; the quote returns the asking prices of live listings for the model year.
SDK call: new CardogClient({ apiKey, appId })
src/lib/cardog.ts
const apiKey = process.env.CARDOG_API_KEY?.trim();
/** The one Cardog client. Server-side only. */
export const cardog: CardogClient | null = apiKey
? new CardogClient({ apiKey, appId: "larkspur-trade-in", baseUrl: process.env.CARDOG_BASE_URL || undefined })
: null;
export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };
/** Where the store is: comparable listings are searched around it. */
export const DEALER_LOCATION = {
lat: Number(process.env.DEALER_LAT ?? 43.5448),
lng: Number(process.env.DEALER_LNG ?? -80.2482),
radius: Number(process.env.DEALER_RADIUS_KM ?? 250),
};
// The v2 response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type MarketQuote = Awaited<ReturnType<V2["quotes"]["getByRef"]>>;
export type ListingSearch = Awaited<ReturnType<V2["listings"]["search"]>>;
export type WindowSticker = Awaited<ReturnType<V2["vin"]["windowSticker"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
type ListingsV2SearchParams = NonNullable<Parameters<V2["listings"]["search"]>[0]>;
export interface Result<T> {
data: T | null;
call: CallRecord;
error: string | null;
}src/lib/cardog.ts
export function decodeVin(vin: string, step: string) {
return run<VinIdentity>({
step,
vin,
key: "decode",
method: "cardog.v2.vin.getByVin",
args: [vin],
http: `GET /v2/vin/${vin}`,
returns: "Identity card: year, make, model, trim, refs, build grains",
live: (c) => c.v2.vin.getByVin(vin),
});
}src/lib/cardog.ts
export function readMarketQuote(modelYearRef: string, vin: string, step: string) {
return run<MarketQuote>({
step,
vin,
key: "quote",
method: "cardog.v2.quotes.getByRef",
args: [modelYearRef],
http: `GET /v2/quotes/${modelYearRef}`,
returns: "Asking-price quartiles, live count and days on market for the model year",
live: (c) => c.v2.quotes.getByRef(modelYearRef),
});
}4. Add accounts and three roles
Customers, appraisers and the desk manager sign in with email and password. role is a user field that sign-up cannot set, so anyone who signs up is a customer; staff are made in the database. Every server action checks the role itself, and each role lands on its own screen.
authSecret() picks the secret that signs sessions: yours whenever BETTER_AUTH_SECRET is set, and the development placeholder only on your machine and during next build. A production server with no secret stops, and one still on the placeholder logs a warning.
src/lib/auth.ts
function authSecret(): string {
const set = process.env.BETTER_AUTH_SECRET?.trim();
const production = process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build";
if (set) {
if (production && set === DEV_SECRET) {
console.warn("[larkspur] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
}
return set;
}
if (!production) return DEV_SECRET;
throw new Error("[larkspur] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}src/lib/auth.ts
export const auth = betterAuth({
appName: "Larkspur Motors",
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3022",
secret: authSecret(),
database: drizzleAdapter(db, {
provider: "sqlite",
schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
}),
emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
user: {
additionalFields: {
role: { type: "string", required: false, defaultValue: "customer", input: false },
},
},
plugins: [nextCookies()],
});src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);src/lib/session.ts
export async function requireRole(...roles: Role[]): Promise<Viewer> {
const v = await requireViewer();
if (!roles.includes(v.role)) redirect(HOME[v.role]);
return v;
}5. Check the VIN as it is typed
Most bad VINs are typos, and the ninth character catches them. Check it in the browser before spending a request: an I or O is almost always a 1 or 0, and a check digit that does not add up means a character is wrong.
src/lib/vin.ts
export function checkVin(vin: string): VinCheck {
if (!vin) return { state: "empty" };
const bad = vin.match(/[IOQ]/);
if (bad) {
const hint = bad[0] === "I" ? "1" : "0";
return { state: "invalid", message: `VINs never use the letter ${bad[0]}. It's probably the number ${hint}.` };
}
if (/[^A-HJ-NPR-Z0-9]/.test(vin)) return { state: "invalid", message: "Only letters and numbers." };
if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
const expected = expectedCheckDigit(vin);
if (expected && expected !== vin[8]) {
return {
state: "check-mismatch",
message: `The 9th character should be ${expected} for this VIN. Check for a mistyped character.`,
};
}
return { state: "ok", message: "17 characters, check digit matches." };
}6. Give an instant estimate on the website
The store's public page asks for the VIN, the kilometres and one of four conditions. The route decodes the VIN, reads the quote for its model year and works a range from the middle half of the market: adjusted for kilometres against a typical car of that year, less the condition's reconditioning, the store's pack and its target gross.
Reads are held for an hour for each VIN, so a customer who books right after the estimate does not cost two more calls.
SDK call: cardog.v2.quotes.getByRef(modelYearRef)
What comes back. priceP25, priceMedian and priceP75 of live listings for the model year, the live count, average days listed and asOf.
src/lib/reads.ts
export async function readForEstimate(vin: string, step: string): Promise<EstimateRead> {
const hit = held.get(vin);
if (hit && Date.now() - hit.at < HOUR) return hit.read;
const decode = await decodeVin(vin, step);
const calls = [decode.call];
if (!decode.data) return { facts: null, calls, error: decode.error ?? "That VIN did not decode." };
const modelYear = decode.data.refs?.modelYear;
if (!modelYear) {
return {
facts: buildTradeFacts(vin, { decode: decode.data }),
calls,
error: "We could not place this VIN's model year, so there is no market to price it from.",
};
}
const quote = await readMarketQuote(modelYear, vin, step);
calls.push(quote.call);
const read: EstimateRead = {
facts: buildTradeFacts(vin, { decode: decode.data, quote: quote.data }),
calls,
error: quote.data ? null : quote.error,
};
if (!read.error) held.set(vin, { at: Date.now(), read });
return read;
}src/lib/valuation.ts
export function instantEstimate(
m: { p25: number | null; p75: number | null; median: number | null },
year: number,
km: number,
condition: Condition
): Estimate | null {
if (m.p25 == null || m.p75 == null || m.median == null) return null;
const c = conditionOf(condition);
const kmAdj = kmAdjustment(m.median, km, typicalKm(year));
const costs = c.recon + DEFAULTS.pack + DEFAULTS.gross;
const low = Math.max(500, round(m.p25 * c.retail + kmAdj - costs));
const high = Math.max(low + 500, round(m.p75 * c.retail + kmAdj - costs));
return {
low,
high,
basis: { p25: m.p25, p75: m.p75, kmAdj, recon: c.recon, pack: DEFAULTS.pack, gross: DEFAULTS.gross, factor: c.retail },
};
}src/app/api/estimate/route.ts
import { NextResponse } from "next/server";
import { readForEstimate } from "@/lib/reads";
import { CONDITIONS, instantEstimate, type Condition } from "@/lib/valuation";
import { checkVin, normalizeVin } from "@/lib/vin";
/**
* POST /api/estimate { vin, km, condition }
* The website's instant estimate. Public: anyone on the store's site can
* ask. Two Cardog calls (decode, quote), held for an hour per VIN.
*/
export async function POST(req: Request) {
const body = (await req.json().catch(() => ({}))) as { vin?: string; km?: number; condition?: string };
const vin = normalizeVin(String(body.vin ?? ""));
const km = Math.round(Number(body.km));
const condition = String(body.condition ?? "") as Condition;
const check = checkVin(vin);
if (vin.length !== 17 || check.state === "invalid") {
return NextResponse.json({ error: "Enter the 17-character VIN." }, { status: 400 });
}
if (!Number.isFinite(km) || km <= 0 || km > 1_500_000) {
return NextResponse.json({ error: "Enter the kilometres on the odometer." }, { status: 400 });
}
if (!CONDITIONS.some((c) => c.id === condition)) {
return NextResponse.json({ error: "Pick the condition that fits best." }, { status: 400 });
}
const read = await readForEstimate(vin, "Instant estimate");
const f = read.facts;
const estimate = f?.market && f.year ? instantEstimate(f.market, f.year, km, condition) : null;
return NextResponse.json({
facts: f,
estimate,
calls: read.calls,
error: read.error ?? (f && !estimate ? "There are not enough live listings to price this one online. Book it and we will look in person." : null),
});
}
7. Book the appraisal
Book my appraisal signs the customer in or up on the way and saves the appraisal with the facts and the estimate they saw. It lands in the lot's queue, and the customer follows it under My trade.
src/app/actions.ts
export async function bookAppraisal(form: FormData) {
const me = await requireRole("customer");
const vin = normalizeVin(String(form.get("vin") ?? ""));
const km = parseKm(form.get("km"));
const condition = String(form.get("condition") ?? "");
if (checkVin(vin).state === "invalid" || vin.length !== 17 || !km || !isCondition(condition)) {
redirect("/?error=details");
}
const read = await readForEstimate(vin, "Book an appraisal");
if (!read.facts) redirect("/?error=decode");
const est = read.facts.market && read.facts.year ? instantEstimate(read.facts.market, read.facts.year, km, condition) : null;
const row = createAppraisal({
status: "booked",
customerId: me.id,
customerName: me.name,
customerPhone: String(form.get("phone") ?? "").trim() || null,
facts: read.facts,
declaredKm: km,
declaredCondition: condition,
estimate: est ? { low: est.low, high: est.high } : null,
calls: read.calls,
actor: me,
});
redirect(`/trade/${row.id}`);
}8. Walk the car on a phone
The appraiser's screen is built for one hand on a lot: the odometer, thirteen OK-or-work checks with a cost on each that needs work, and eight photo slots that open the camera. Everything autosaves, and a pinned bar keeps the reconditioning total in view.
Send to desk refuses until the odometer is read, every item is checked, and the front, rear and odometer photos are in.
src/app/actions.ts
export async function saveWalkaround(
id: string,
input: { odometerKm: number | null; checklist: Checklist; lotNote: string }
): Promise<{ ok: boolean; recon: number }> {
await requireRole("appraiser", "desk");
const a = getAppraisal(id);
if (!a || a.status !== "walking") return { ok: false, recon: 0 };
const checklist: Checklist = {};
for (const item of CHECK_ITEMS) {
const c = input.checklist[item.id];
const state = c && ["unchecked", "ok", "work"].includes(c.state) ? c.state : "unchecked";
const cost = state === "work" ? Math.max(0, Math.min(50_000, Math.round(Number(c?.cost) || 0))) : 0;
checklist[item.id] = { state, cost };
}
const odometerKm =
input.odometerKm && input.odometerKm > 0 && input.odometerKm < 1_500_000 ? Math.round(input.odometerKm) : null;
updateAppraisal(id, { checklist, odometerKm, lotNote: input.lotNote.slice(0, 2000) || null });
return { ok: true, recon: reconTotal(checklist) };
}src/app/api/appraisals/[id]/photos/route.ts
import { NextResponse } from "next/server";
import { db, schema } from "@/db";
import { getAppraisal, photoFor } from "@/lib/appraisals";
import { savePhoto } from "@/lib/photos";
import { getViewer } from "@/lib/session";
import { PHOTO_SLOTS } from "@/lib/trade-file";
import { eq } from "drizzle-orm";
/** POST /api/appraisals/{id}/photos (multipart: slot, file). Staff only, while the car is on the lot. */
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const me = await getViewer();
if (!me || me.role === "customer") return NextResponse.json({ error: "Staff only." }, { status: 403 });
const { id } = await params;
const a = getAppraisal(id);
if (!a || a.status !== "walking") return NextResponse.json({ error: "Not on the lot." }, { status: 409 });
const form = await req.formData();
const slot = String(form.get("slot") ?? "");
const file = form.get("file");
if (!PHOTO_SLOTS.some((s) => s.id === slot)) return NextResponse.json({ error: "Unknown slot." }, { status: 400 });
if (!(file instanceof Blob) || file.size === 0 || file.size > 8 * 1024 * 1024) {
return NextResponse.json({ error: "One image, up to 8 MB." }, { status: 400 });
}
const bytes = new Uint8Array(await file.arrayBuffer());
const path = await savePhoto(id, slot, bytes);
const existing = photoFor(id, slot);
if (existing) {
db.update(schema.photo)
.set({ file: path, bytes: bytes.length, updatedAt: new Date() })
.where(eq(schema.photo.id, existing.id))
.run();
} else {
db.insert(schema.photo).values({ id: crypto.randomUUID(), appraisalId: id, slot, file: path, bytes: bytes.length }).run();
}
return NextResponse.json({ ok: true });
}src/app/actions.ts
export async function sendToDesk(id: string): Promise<{ error: string } | void> {
const me = await requireRole("appraiser", "desk");
const a = getAppraisal(id);
if (!a || a.status !== "walking") return { error: "This appraisal is not on the lot." };
if (!a.odometerKm) return { error: "Enter the odometer reading." };
if (checkedCount(a.checklist) < CHECK_ITEMS.length) return { error: "Check every item first." };
const shots = photosFor(id).map((p) => p.slot);
const missing = ["front", "rear", "odometer"].filter((s) => !shots.includes(s));
if (missing.length) return { error: "Add the front, rear and odometer photos." };
updateAppraisal(id, { status: "desk", walkedAt: new Date() });
logEvent(
id,
me,
"walked",
`Walk-around done: ${a.odometerKm.toLocaleString("en-CA")} km, ${cad(reconTotal(a.checklist))} reconditioning, ${shots.length} photos`
);
redirect("/lot?sent=" + encodeURIComponent(vehicleName(a)));
}
9. Read the market for the desk
When the desk opens a sheet, four reads run in parallel: the quote again, live listings of the same model a year either side and within 40,000 km, near the store, the factory window sticker, and the recalls issued for the model and year. Refresh market runs them again.
The listings become the comparables, with the trade's own listing left out if it is for sale somewhere. The sticker comes back as the original document or the reason there is none. Recalls are matched by model year, and the sheet labels them that way.
SDK call: cardog.v2.listings.search({ filters })
What comes back. Listing rows with price, odometer, year and trim, filtered by model ref, year and odometer ranges and location: { lat, lng, radius }.
src/lib/cardog.ts
export function searchComparables(
q: { modelRef: string; year: number; km: number },
vin: string,
step: string
) {
const params: ListingsV2SearchParams = {
filters: {
models: [q.modelRef as `model:${string}`],
year: { min: q.year - 1, max: q.year + 1 },
odometer: { min: Math.max(0, q.km - 40_000), max: q.km + 40_000 },
location: DEALER_LOCATION,
},
pagination: { page: 1, limit: 12 },
sort: { field: "price", order: "asc" },
};
const f = params.filters!;
return run<ListingSearch>({
step,
vin,
key: "comps",
method: "cardog.v2.listings.search",
args: [params],
http:
`GET /v2/listings/search?models=${q.modelRef}&year=${f.year!.min}-${f.year!.max}` +
`&odometer=${f.odometer!.min}-${f.odometer!.max}&lat=${DEALER_LOCATION.lat}&lng=${DEALER_LOCATION.lng}` +
`&radius=${DEALER_LOCATION.radius}&sort=price&order=asc&limit=12`,
returns: "Live listings of the same model, a year either side, within 40,000 km, near the store",
live: (c) => c.v2.listings.search(params),
});
}src/lib/cardog.ts
export function readWindowSticker(vin: string, step: string) {
return run<WindowSticker>({
step,
vin,
key: "sticker",
method: "cardog.v2.vin.windowSticker",
args: [vin],
http: `GET /v2/vin/${vin}/window-sticker`,
returns: "The factory window sticker held for this VIN, or the reason there is none",
live: (c) => c.v2.vin.windowSticker(vin),
});
}src/lib/cardog.ts
export function readModelYearRecalls(modelYearRef: string, vin: string, step: string) {
return run<ModelYearRecalls>({
step,
vin,
key: "recalls",
method: "cardog.v2.recalls.entity",
args: [modelYearRef, { limit: 20 }],
http: `GET /v2/recalls/entity/${modelYearRef}?limit=20`,
returns: "Recall campaigns issued for the model year (Transport Canada and NHTSA)",
live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 20 }),
});
}src/lib/reads.ts
export async function readForDesk(prev: TradeFacts, km: number, step: string): Promise<DeskRead> {
const { vin } = prev;
const my = prev.refs.modelYear;
const model = prev.refs.model;
const [quote, comps, sticker, recalls] = await Promise.all([
my ? readMarketQuote(my, vin, step) : null,
model && prev.year ? searchComparables({ modelRef: model, year: prev.year, km }, vin, step) : null,
readWindowSticker(vin, step),
my ? readModelYearRecalls(my, vin, step) : null,
]);
const near =
dataMode === "fixture"
? "across Canada"
: `within ${DEALER_LOCATION.radius} km of the store`;
const fresh = buildTradeFacts(vin, {
decode: null,
quote: quote?.data,
sticker: sticker.data,
recalls: recalls?.data,
});
return {
facts: {
...prev,
market: fresh.market ?? prev.market,
sticker: fresh.sticker ?? prev.sticker,
recalls: fresh.recalls ?? prev.recalls,
},
comps: comps?.data ? buildComps(comps.data, vin, near) : null,
calls: [quote, comps, sticker, recalls].filter((r) => r !== null).map((r) => r.call),
};
}
10. Price the offer on the sheet
Every offer is worked back from what the car will retail for. The anchor is the comparables' median asking price when there are three or more, and the market median otherwise. Add or take off the kilometres against the comparables' median, then take off the walk-around's reconditioning, the pack and the target gross.
The desk can change the anchor, the rate per kilometre, the pack and the gross on each sheet, and type over the suggested offer. Sending it records an event in the appraisal's history; the customer accepts or declines in their account.
src/lib/valuation.ts
export function worksheet(i: DeskInputs, km: number, referenceKm: number, recon: number): Worksheet {
const kmAdj = kmAdjustment(i.anchor, km, referenceKm, i.perKm);
const offer = Math.max(0, round(i.anchor + kmAdj - recon - i.pack - i.gross, 100));
return { anchor: i.anchor, kmAdj, recon, pack: i.pack, gross: i.gross, offer, referenceKm };
}src/app/actions.ts
export async function sendOffer(
id: string,
input: { offer: number; note: string; desk: DeskInputs }
): Promise<{ error: string } | void> {
const me = await requireRole("desk");
const a = getAppraisal(id);
if (!a || !["desk", "offered", "declined"].includes(a.status)) return { error: "This appraisal is not at the desk." };
const offer = Math.round(Number(input.offer));
if (!Number.isFinite(offer) || offer < 0 || offer > 2_000_000) return { error: "Enter the offer in dollars." };
const revised = a.status !== "desk";
updateAppraisal(id, {
status: "offered",
offer,
offerNote: input.note.trim().slice(0, 1000) || null,
desk: {
anchor: Math.round(input.desk.anchor) || 0,
anchorSource: input.desk.anchorSource,
perKm: Number(input.desk.perKm) || 0,
pack: Math.round(input.desk.pack) || 0,
gross: Math.round(input.desk.gross) || 0,
},
offeredBy: me.id,
offeredAt: new Date(),
respondedAt: null,
});
logEvent(
id,
me,
revised ? "revised" : "offered",
revised ? `Offer revised from ${cad(a.offer)} to ${cad(offer)}` : `Offer of ${cad(offer)} sent to ${a.customerName}`
);
revalidatePath(`/desk/${id}`);
}src/app/actions.ts
export async function respondToOffer(id: string, answer: "accepted" | "declined") {
const me = await requireRole("customer");
const a = getAppraisal(id);
if (!a || a.customerId !== me.id || a.status !== "offered") return;
updateAppraisal(id, { status: answer, respondedAt: new Date() });
logEvent(id, me, answer, answer === "accepted" ? `Accepted the offer of ${cad(a.offer)}` : `Declined the offer of ${cad(a.offer)}`);
revalidatePath(`/trade/${id}`);
}
11. Deploy
Larkspur ships as one container with SQLite and the photos on a volume. Build the image, mount the volume, and set the key, the auth secret and the public URL in the host's environment. On a serverless host, swap SQLite for a hosted database and the photo folder for object storage; the Cardog code does not change.
Dockerfile
# Larkspur trade-in appraisal: one container, SQLite and photos on a mounted volume.
# docker build -t larkspur .
# docker run -p 3022:3022 -v larkspur-data:/app/data \
# -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host larkspur
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /app ./
VOLUME /app/data
EXPOSE 3022
CMD ["pnpm", "start"]terminal
docker build -t larkspur .
docker run -p 3022:3022 -v larkspur-data:/app/data \
-e CARDOG_API_KEY=your_cardog_api_key \
-e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
-e BETTER_AUTH_URL=https://trade.example.com \
larkspurNext steps
- Market data: Quotes, comparables and the price history behind them.
- VIN decode: Every field the decode returns.
- Window sticker: The factory sticker for a VIN, or the reason there is none.
Was this helpful?