No description
  • TypeScript 95.1%
  • JavaScript 1.4%
  • Dockerfile 1.3%
  • Shell 1.1%
  • CSS 1.1%
Find a file
2026-09-16 01:32:29 +02:00
.agents/skills Fuel logging 2026-09-15 22:04:57 +02:00
docs Fuel logging 2026-09-15 22:04:57 +02:00
prisma Added basic service records 2026-09-16 01:32:29 +02:00
public Fuel logging 2026-09-15 22:04:57 +02:00
scripts Added basic service records 2026-09-16 01:32:29 +02:00
src Added basic service records 2026-09-16 01:32:29 +02:00
.dockerignore Fuel logging 2026-09-15 22:04:57 +02:00
.env.example Improved Docker templates 2026-09-15 22:25:08 +02:00
.gitignore One docker for both db providers 2026-09-16 00:16:53 +02:00
AGENTS.md Fuel logging 2026-09-15 22:04:57 +02:00
CLAUDE.md Added basic service records 2026-09-16 01:32:29 +02:00
components.json Fuel logging 2026-09-15 22:04:57 +02:00
docker-compose.sqlite.yml One docker for both db providers 2026-09-16 00:16:53 +02:00
docker-compose.yml One docker for both db providers 2026-09-16 00:16:53 +02:00
docker-entrypoint.sh One docker for both db providers 2026-09-16 00:16:53 +02:00
Dockerfile One docker for both db providers 2026-09-16 00:16:53 +02:00
eslint.config.mjs Fuel logging 2026-09-15 22:04:57 +02:00
LICENSE Fuel logging 2026-09-15 22:04:57 +02:00
next.config.ts Added basic service records 2026-09-16 01:32:29 +02:00
package-lock.json Fuel logging 2026-09-15 22:04:57 +02:00
package.json Added basic service records 2026-09-16 01:32:29 +02:00
postcss.config.mjs Fuel logging 2026-09-15 22:04:57 +02:00
prisma7.config.ts Fuel logging 2026-09-15 22:04:57 +02:00
README.md Added basic service records 2026-09-16 01:32:29 +02:00
skills-lock.json Fuel logging 2026-09-15 22:04:57 +02:00
tsconfig.json Fuel logging 2026-09-15 22:04:57 +02:00

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_IMAGE in the stack's environment to a tag you have pushed to a registry, and the build: 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