- TypeScript 95.1%
- JavaScript 1.4%
- Dockerfile 1.3%
- Shell 1.1%
- CSS 1.1%
| .agents/skills | ||
| docs | ||
| prisma | ||
| public | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| components.json | ||
| docker-compose.sqlite.yml | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| LICENSE | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| prisma7.config.ts | ||
| README.md | ||
| skills-lock.json | ||
| tsconfig.json | ||
Carly
Self-hosted mileage and fuel tracking for the vehicles you actually drive. Log fill-ups, see what your car really costs per kilometre, and share a vehicle with whoever else drives it.
- Vehicles — name, year, type, plate, VIN, owner, fuel type, photo.
- Fuel entries — date, odometer, litres, cost, full-tank and missed-fill-up flags, notes; distance, consumption and price per litre are worked out for you.
- Service records — date, odometer, what was done, cost, notes in markdown, and the invoices or photos that go with it (PDF or image) attached to the record.
- Dashboard — distance covered since the first fill-up, average / best / worst consumption, total fuel cost, and a monthly cost chart with service spend stacked on top of fuel. Fuel and service records live on their own tabs.
- Sharing — invite another account by email; they can log fill-ups on your vehicle.
- Your units — kilometres or miles, litres or gallons, L/100 km, mpg or km/L, your currency, your date format.
- Languages — English, Slovenian, German.
- Import / export — CSV in Carly's own format or straight out of LubeLogger.
Built with Next.js, TypeScript, Prisma and Auth.js. Runs on PostgreSQL or SQLite.
Quick start with Docker Compose
git clone https://git.intralab.si/miha-meglic/carly.git
cd carly
cp .env.example .env
Edit .env and set one variable:
POSTGRES_PASSWORD=... # anything long
That really is the only one. The session secret is generated on first start and
kept in the carly-config volume, and Auth.js works out its own public URL
from the incoming request — set AUTH_SECRET or AUTH_URL yourself only if you
want to pin them.
Then:
docker compose up -d
The first run builds the image from the checkout, which takes a few minutes;
after that it starts straight away. Set CARLY_IMAGE in .env to pull a
published tag instead.
Open the instance and create the first account — it becomes the
administrator. After that, sign-up is closed: new people are let in with
invitation links from the admin page, unless you set ALLOW_REGISTRATION=true.
Migrations run automatically on every start, so upgrading is docker compose pull && docker compose up -d.
Portainer, Dockhand and friends
The stack uses named volumes only — no host paths, no bind mounts — so it
deploys unchanged, and POSTGRES_PASSWORD is the only variable you have to
fill in.
One thing to watch: a stack pasted into a web editor has no build context,
so the build: section cannot run. Pick whichever fits:
- Deploy from the Git repository. Portainer's Repository stack type and Dockhand's Git source both clone the repo first, so the build works and you get the image straight from source. Nothing else to configure.
- Use a published image. Set
CARLY_IMAGEin the stack's environment to a tag you have pushed to a registry, and thebuild:section is ignored.
Either way, upgrading is "re-pull and redeploy": migrations are applied by the container on start.
The health check at /api/health reports healthy only once the database is
actually reachable, so a restart policy reacts to a broken database rather than
to a process that is merely running.
If something is misconfigured the container says so in its log rather than
failing obscurely — a SQLite DATABASE_URL handed to a PostgreSQL image, a
DATABASE_PROVIDER the image was not built for, or a database that never
becomes reachable each produce a message naming the fix.
One image covers both databases, so there is no separate SQLite tag to pick:
docker-compose.sqlite.yml differs only in pointing DATABASE_URL at a file.
Configuration
Everything is read from the environment at startup; there is no config file.
| Variable | Default | What it does |
|---|---|---|
DATABASE_PROVIDER |
from the URL | postgresql or sqlite. Only needed when DATABASE_URL does not make it obvious; it is checked against the URL. |
DATABASE_URL |
— | Required. Connection string, or file:/data/carly.db for SQLite. |
AUTH_SECRET |
generated | Signs session cookies. Generated on first start into /config/auth-secret when unset. Changing it signs everyone out. |
AUTH_URL |
from the request | Public URL of the instance. Only needed if your proxy does not send the usual forwarded headers. |
AUTH_TRUST_HOST |
true |
Leave on behind a reverse proxy. |
ALLOW_REGISTRATION |
false |
true opens public sign-up. The first account is always allowed regardless. |
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
unset | Enables the Google sign-in button. |
AUTH_OIDC_ISSUER / AUTH_OIDC_ID / AUTH_OIDC_SECRET |
unset | Enables a generic OpenID Connect provider. |
AUTH_OIDC_NAME |
Single sign-on |
Label on the OIDC button. |
AUTH_OIDC_CLIENT_AUTH |
client_secret_basic |
Set to client_secret_post if token exchange fails. |
AUTH_LINK_ACCOUNTS_BY_EMAIL |
true |
Lets an identity provider attach to an existing local account with the same email. See the note below. |
CARLY_SKIP_MIGRATIONS |
false |
true starts the server without touching the schema. |
CARLY_PORT |
3000 |
Host port, compose only. |
DB_WAIT_RETRIES |
30 |
How many two-second attempts to reach the database before giving up. |
CARLY_SECRET_FILE |
/config/auth-secret |
Where the generated session secret is kept. Mount a volume there, or everyone is signed out whenever the container is recreated. |
Single sign-on
Carly speaks plain OpenID Connect, so Authentik, Keycloak, Zitadel, Pocket ID and similar all work. Point it at the issuer and register the callback URL:
{AUTH_URL}/api/auth/callback/oidc # generic OIDC
{AUTH_URL}/api/auth/callback/google # Google
AUTH_LINK_ACCOUNTS_BY_EMAIL is on by default: if somebody signs in through
your identity provider with an email that already has a local account, the two
are joined instead of failing. That is what you want when you control the
provider, and it means trusting the provider's email claim — set it to false
if you do not.
Identity providers cannot carry an invitation token, so while sign-up is closed the provider buttons only appear on the sign-in page. Invited users create their account with a password first and can link a provider afterwards.
SQLite instead of PostgreSQL
docker compose -f docker-compose.sqlite.yml up -d
Same image as the PostgreSQL stack — a file: URL is all it takes:
docker run -v carly-data:/data -v carly-config:/config -e DATABASE_URL=file:/data/carly.db -p 3000:3000 carly
That gives you one container and one volume, with the database at
/data/carly.db. It is a good fit for a household or a handful of users;
PostgreSQL is the better answer if several people write at once, since SQLite
locks the whole file for every write.
Back it up by copying the volume while the container is stopped, or with
sqlite3 /data/carly.db ".backup /data/backup.db" while it runs.
Backups
PostgreSQL
docker compose exec db pg_dump -U carly carly > carly-$(date +%F).sql
SQLite
docker compose -f docker-compose.sqlite.yml stop app
docker run --rm -v carly_carly-data:/data -v "$PWD":/backup alpine \
tar czf /backup/carly-$(date +%F).tar.gz -C /data .
docker compose -f docker-compose.sqlite.yml start app
Vehicle photos and the documents attached to service records are stored in the database rather than on disk, so a dump is a complete backup — and a larger one than you might expect from a mileage log.
Either way, users can also export a vehicle's entries to CSV from its Fuel tab.
Behind a reverse proxy
Carly listens on port 3000 and needs no special headers beyond the usual ones.
Set AUTH_URL to the public URL and keep AUTH_TRUST_HOST=true.
Caddy
carly.example.com {
reverse_proxy carly:3000
}
Traefik labels
labels:
- traefik.enable=true
- traefik.http.routers.carly.rule=Host(`carly.example.com`)
- traefik.http.routers.carly.tls.certresolver=letsencrypt
- traefik.http.services.carly.loadbalancer.server.port=3000
Running from source
Requires Node.js 20.19+, 22.12+ or 24+.
npm install
cp .env.example .env # the defaults there use SQLite
npm run dev
npm run dev generates the Prisma client and applies any pending migrations
before starting, so a fresh checkout comes up with a working database at
./data/carly.db. The first account you create becomes the administrator.
npm run seed:demo fills a demo account with a year of realistic fill-ups.
Useful scripts:
| Command | What it does |
|---|---|
npm run dev |
Development server on port 3000, migrations applied first. |
npm run build / npm start |
Production build and server. |
npm run check |
Types, lint, translation catalogues and the consumption maths. |
npm run smoke |
End-to-end pass over a running server. |
npm run db:studio |
Prisma Studio. |
See CLAUDE.md for how the code is laid out.
How consumption is calculated
Fuel burned is only known between two points where the tank was equally full, so consumption is measured from one full tank to the next:
consumption = fuel added since the previous full tank (this fill-up included)
÷ distance on the odometer since that full tank
A partial fill-up shows no figure of its own; its litres roll into the next full tank. An entry marked as a missed fill-up means somebody refuelled without logging it, so that interval's litres are incomplete — Carly skips the figure for that interval and starts measuring again from there.
This matches LubeLogger exactly, which keeps imported histories consistent with
what they showed before. npm run check:consumption verifies it against a real
export.
CSV format
See docs/csv-format.md.
Licence
MIT.
Miha Meglič — miha.meglic@intralab.si