Gör det lättare.
A self-hosted weight and habit tracker. It shows a smoothed trend line instead of this morning's number, and it works out what you burn from your own logged data instead of a formula.
Swedish interface. AGPL-3.0. Runs on one small server.
Two ideas, and everything else follows from them.
A daily weight is mostly noise. Body weight swings a kilo overnight on salt, sleep and water, so a number that moves the wrong way says almost nothing about the week. Vikt stores every reading and draws a 10-day exponentially smoothed trend through them, Hacker's Diet style. The raw readings stay visible as faint points behind the line, because hiding them would be pretending the noise is not there — but the line is what the app is about, and it gets the hero position on every screen.
Your maintenance level is measurable, not calculable. The usual approach takes your height, weight, age and a guessed activity multiplier and produces a number that is wrong for you specifically. Vikt instead back-calculates it: over a rolling 28-day window it takes what you actually ate and what the trend actually did, and solves for the only figure that explains both. Mifflin-St Jeor is the fallback for the first fortnight, and the app says which one you are looking at and how confident it is.
Both of those live in packages/shared/src/calc/, are pure functions, and are
covered by tests that check the two projections — "on plan" and "at current
pace" — agree when they should. CLAUDE.md §4 specifies the arithmetic exactly.
- Weight, with the trend line, waist-to-height on the same axis, and projections to a goal by two independent routes shown side by side.
- Food, by barcode, by name against Open Food Facts and Livsmedelsverket, by describing a meal in a sentence, by photographing the plate, by photographing the nutrition label of something no database knows, or by typing an estimate for the pizzeria down the road. Portions resolve through what you logged last time, your own definitions, and a table of Swedish household measures.
- Meals: the dishes you cook, with a portion count and figures per portion, built with the same tools and logged at any number of portions as ordinary rows that keep the meal's name. One photo each, kept on your own server. A meal can be shared with everyone on the same installation under a display name, and whoever saves or logs it gets a copy of their own.
- The day: energy, mood, sleep, steps, alcohol, activity, measurements.
- Milestones and a savings pot for the things you did not buy.
- Offline. Everything you log is written locally first and syncs when the network comes back, so a meal logged in a shop basement is logged.
- An optional local LLM layer for parsing meals and suggesting recipes, which degrades to manual entry when the machine running it is off. It never produces a calorie figure: it names foods, and the database prices them. The one exception is a photographed nutrition label, which it transcribes, and which is only saved once the figures add up and you have confirmed each one.
Two rules the whole app is built on: there is no failure state — a missed day renders as a gap, never as red — and absent is not zero. A day nobody logged is unknown, and the app says "inte än" rather than inventing a number.
pnpm install
cp infra/.env.example .env # then edit it, see below
pnpm dev:db # Postgres 16 in Docker, nothing else
pnpm --filter api db:migrate # apply the checked-in migrations
pnpm --filter api invite # mint an invite code and print it
pnpm dev # API on 127.0.0.1:3000, Vite on 0.0.0.0:5173The landing page is at http://localhost:5173/ and the app at
http://localhost:5173/app. From a phone on the same network, use the LAN
address Vite prints: the phone talks to Vite, which proxies /api to the API on
loopback. The API is never bound to the LAN, not even in development.
Editing .env, at minimum:
SESSION_SECRET—openssl rand -hex 32. The process refuses to start while this is missing, short, or still the example value.POSTGRES_PASSWORDand the matching password inDATABASE_URL.COOKIE_SECURE=falsefor plain HTTP on the LAN. Keep ittrueanywhere reachable from the internet.SEED_PASSWORD, if you wantpnpm --filter api seed:devto create the shared development account. There is deliberately no default.
Optional:
-
PUBLIC_BASE_URLis where this installation is reachable, as an absolute URL. Every link in every email is built from it (D109). Without it a link would be relative, which is fine in a page and useless in an inbox: no mail client has a base to resolve it against, and that is exactly how an invite once went out with a dead link in it.assertProdSecretsrefuses a value that is not absolute, andPUBLIC_ORIGINis still read as a fallback. -
SMTP is configured in the app, under Administration, Mejlserver (D102). The
SMTP_*variables are read once, on first boot, and imported into the encrypted settings. Leave them empty and everything still works: reset says it cannot send, and approving an invite shows you the code to pass on by hand. -
OLLAMA_URLenables the LLM layer. Empty means off, and the controls that depend on it are simply not rendered. -
LLM_VISION_MODELnames the model that gets sent photographs of food (D143). Empty means the photo quick action does not exist. There is no default and you should not guess one: Ollama reports avisioncapability for models that accept an image and then answer as though nothing was attached, which is a failure nothing in the app can detect from the flag. Find a tag that works first:pnpm --filter api probe:vision --selftest # does any model look? pnpm --filter api probe:vision plate.jpg qwen3-vl:8b # what does it see?
The self-test sends a generated picture with known contents and prints SEES or DOES NOT SEE; the second form sends a real photograph and prints the answer with the time it took. Both print what Ollama had resident before and after, which is how
docs/measurements.mdknows that a photograph evicts the coach model on this box and not the text one. The API runs the same self-test at boot and hides the surface when it fails, so the probe is for choosing a tag before you configure it.
pnpm typecheck
pnpm lint # includes the multi-user isolation rule, see D15
pnpm test
pnpm --filter api contract:food # hits the live food APIs; needs the network
pnpm --filter api probe:vision --selftest # can a model on OLLAMA_URL see?The first two also run as a pre-commit hook, installed by pnpm install. All of
them except contract:food run in GitHub Actions on every push and pull request,
which is what STATE.md's verification line cites: a claim that the tree is clean
is worth what has actually been run, and it once said so while HEAD had 27 lint
errors (D98).
cd infra
cp .env.example .env # real secrets; DATABASE_URL host is `postgres`
docker compose up -d --buildPostgres, the API and nginx. Migrations run from the API container's entrypoint.
Only nginx publishes a port, and only to 127.0.0.1:8080 on the Docker host,
where a reverse proxy terminates TLS in front of it. See D12.
Set TRUST_PROXY to the address or CIDR of the immediate peer, not a hop
count, and read D14 before you do: getting this wrong lets anyone who can open a
socket to the API claim to be any IP they like.
Two routes, same three containers:
infra/docker-compose.ymlbuilds from source on the machine you are standing at:cd infra && cp .env.example .env && $EDITOR .env && docker compose up -d --build.infra/docker-compose.portainer.ymlpulls prebuilt images from a registry, for a host with no source tree on it. Everything configurable is a stack environment variable, because that is the form a container-management UI gives you.
Each is off by default, each is an environment variable, and none of them can be changed from the admin UI: a toggle in a web interface that exposes a public endpoint is an attack surface, and a self-hoster edits the file once.
| Mode | On means | Off means |
|---|---|---|
LANDING_ENABLED |
The public landing page is served at /. |
/ redirects to /app and the landing bundle is never served. |
REQUEST_ENABLED |
The request form is served at /kod and the endpoint it posts to is registered. |
Both are 404: the path does not exist and neither does the route. |
LLM_ENABLED |
The phase 8 surfaces exist. Needs OLLAMA_URL. |
They are absent, not greyed out. |
LLM_VISION_MODEL |
Photographing a meal is offered, once the boot check has seen that tag describe a picture. | The photo quick action is absent. So is it when the check has not run, could not reach the box, or found a model that does not look. |
REQUEST_ENABLED is separate from LANDING_ENABLED on purpose (D127). A
landing page is something to read. A request form takes a stranger's name and
address, and every code you approve makes you responsible for that person's
weight, meals and address, under your own name on /integritet. Those are two
different decisions and the common answer is yes to the first and no to the
second. Nothing links to /kod even when it is on: turning it on means handing
the address to somebody, not publishing it. The form keeps its human check, its
honeypot and its rate limit either way.
CONTACT_EMAIL is required wherever either of those first two is true:
/integritet has to name somebody the reader can write to about their own data,
and the API refuses to boot without it. Whoever deploys Vikt is the controller
under the GDPR, not whoever wrote it, so that address cannot live in this
repository.
.github/workflows/release.yml builds and pushes both images on every push to
main that touches code.
The API drains the mail queue itself (D104). There is nothing to start alongside it, and a second API instance would run a second drainer and send everything twice, so one instance is the supported deployment until a lock exists.
pnpm --filter api mail:worker still exists for the separate-process
arrangement and refuses to run unless MAIL_WORKER_IN_PROCESS=false.
To see what the mail actually looks like in a real client, send one of each template to yourself:
pnpm --filter api mail:samples -- --to you@example.comThey go through the queue like everything else, so a delivered set proves the templates, the queue and the drainer together rather than the transport alone.
To make an account an admin — the only way, deliberately:
pnpm --filter api admin -- --email you@example.comBack up nightly, and check that the backups restore:
infra/backup.sh # from cron
infra/restore-check.sh /var/backups/vikt/<file> # safe on the live hostThe app also runs its own scheduled backup, configured under Administration,
Backup. It writes either to a directory or to an S3-compatible bucket
— AWS, Backblaze B2, MinIO, or the S3 endpoint most NAS boxes now ship — with
the access key and secret stored encrypted under SECRET_KEY. The dump is
encrypted before it leaves the process either way, so what reaches the
destination cannot be read without that key.
To back up to a Windows share, mount it on the host and use a directory
destination. Writing SMB directly is not supported: both Node SMB clients
authenticate with NTLMv1, which current Samba and Windows refuse by default, and
hand-writing NTLMv2 is authentication code whose errors are silent (D132, D133).
Mounting inside the container is not the answer either, because mount -t cifs
needs CAP_SYS_ADMIN, which is most of the way to root on the host.
Mount it on the host, then bind-mount the directory into the API container:
# /etc/fstab on the Docker host
//nas.local/backup /mnt/vikt-backup cifs credentials=/root/.smb-vikt,uid=1000,gid=1000,_netdev 0 0# infra/docker-compose.yml, the api service
volumes:
- /mnt/vikt-backup:/backupsThen set the destination to /backups under Administration, Backup, and press
Testa anslutningen: it writes a small file and deletes it again, which is
the only way to find out that the path is writable by the container's user
before the first scheduled run.
See docs/backup.md for where they go, how long they are kept, and the step-by-step restore. A backup that has never been restored is a hope.
To rename the app, with no rebuild:
APP_NAME="Nedåt" docker compose up -d nginxapps/api/ Fastify. routes/ -> services/ -> repositories/, db/
apps/web/ React 18 + Vite + Tailwind. index.html is the landing page,
app/index.html the application
packages/shared/ Zod schemas, shared types, and ALL derived-number math
infra/ compose files, nginx, Dockerfiles, .env.example
eslint-rules/ the multi-user isolation rule
docs/ the graphic profile, the mark, screenshots
The math lives in packages/shared/src/calc/ so the server and the client
compute identical numbers, and everything in it is covered by Vitest.
Three documents, and they are the point rather than decoration:
- CLAUDE.md — the constitution. Non-negotiable rules, the exact arithmetic, the visual direction, and the build order.
- DECISIONS.md — every architectural decision with the reasoning and what was rejected. Append-only. Read this before changing anything structural; most of the traps have been walked into already and the entry explains what happened.
- STATE.md — where the build has got to, what is open, and the measurements from the last pass.
The code is AGPL-3.0. See LICENSE and NOTICE.
Food data cached from Open Food Facts is licensed ODbL and is not part of this repository: it is fetched at runtime into a local cache, and nothing derived from it is committed here. Data from Livsmedelsverket is likewise fetched at runtime.
Vikt is not medical advice. It shows you your own numbers and says how confident it is about them.


