Web application for managing an inventory of 3D-printing material — filament, resin and powder. Material lives in stores, one kind of material per store; it sits in a container (spool, bag, bottle, pail, cartridge) and optionally in a drybox, both with a stored tare weight. A weigh-in is a gross weight typed in from a kitchen scale, and the remaining quantity is what is left after the tare comes off. Between weigh-ins, a consumption is the grams a print used, typed in from the slicer; it comes off the last weigh-in until the next one replaces it. Short IDs for quick retrieval, a shared preset catalogue of manufacturers and containers, per-store sharing with friends, organizations for a stock several people work on, and Telegram-only login.
Stack: React + Vite + Tailwind (frontend) · Hono + tRPC (backend) · Drizzle ORM + PostgreSQL (database)
Product page · Live instance · Container images
- Open @BotFather in Telegram
- Send
/newbot, choose a name and username (e.g.YourFilahubBot) - Note the token (looks like
123456789:ABCdeFG…) - Set a domain (required for the login widget): send
/setdomainto @BotFather, select your bot and enter your domain (e.g.filahub.yourdomain.at– no https://, no path) - Message your new bot with
/id→ it replies with your Telegram user ID (needed for the whitelist and the admin role)
Note:
/idis a built-in feature of this app – once the bot is running, it replies to it with your ID.
cp .env.example .env
# fill in .env: APP_SECRET (e.g. `openssl rand -hex 32`), DATABASE_URL,
# TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, TELEGRAM_ALLOWED_IDS, OWNER_TELEGRAM_ID
# and, if anyone but you will use it, the LEGAL_OPERATOR_* values| Variable | Purpose |
|---|---|
APP_SECRET |
Random secret for signing session tokens |
DATABASE_URL |
PostgreSQL connection string |
TELEGRAM_BOT_TOKEN |
Token from @BotFather |
TELEGRAM_BOT_USERNAME |
Bot username without @ |
TELEGRAM_ALLOWED_IDS |
Allowed Telegram IDs (comma-separated); empty = nobody, unless opened below |
TELEGRAM_OPEN_REGISTRATION |
Set to 1 to let any Telegram account register |
OWNER_TELEGRAM_ID |
Telegram ID of the admin |
APP_BASE_URL |
Public address of this instance — links in Telegram messages; optional |
LEGAL_OPERATOR_NAME |
Who runs this instance — shown in the imprint and privacy policy |
LEGAL_OPERATOR_ADDRESS |
Postal address of the operator — multi-line, see below |
LEGAL_OPERATOR_EMAIL |
Contact address for data protection requests |
LEGAL_OPERATOR_HOSTING |
Who provides the servers (processor under Art. 28 GDPR) |
TRUST_PROXY_HOPS |
Trusted reverse proxies in front of the app (default 1) |
LEGAL_OPERATOR_ADDRESS and LEGAL_OPERATOR_HOSTING may span several lines.
Write the line breaks as \n:
LEGAL_OPERATOR_ADDRESS=Example Street 1\n1234 Town\nSwitzerlandThat form survives every route the value can take — an unquoted config line,
environment: in Compose, docker run -e, and deployment platforms that feed
the variables into a build.
A genuinely quoted multi-line value works too where the format allows it:
LEGAL_OPERATOR_ADDRESS="Example Street 1
1234 Town
Switzerland"But do not paste raw line breaks into a deployment UI. Platforms that build
the image themselves (Coolify, for instance) pass the variables into the build,
where a real newline ends the line and the rest is read as the next
instruction — the build fails. \n avoids that entirely.
Windows line endings, blank lines and stray spaces are cleaned up either way.
So is a value that got escaped a second time on its way in — some platforms
turn \n into \\n when they write the variables into the build.
Do not use <br>: the legal pages are Markdown and raw HTML is discarded
by design, so it would show up as visible text.
Create a PostgreSQL database:
createdb filahubThe app applies pending SQL migrations (db/migrations/) automatically on
startup, so a fresh database initializes itself. For local development you
can alternatively sync the schema directly with npm run db:push; after
schema changes, regenerate the migration files with npm run db:generate.
On startup the app also seeds a preset catalogue of manufacturers and
containers (Polymaker, Prusament, Bambu Lab, eSUN and others). Seeding is
idempotent and never overwrites entries an administrator edited or that came
from an accepted community suggestion. Locally you can run it with
npm run db:seed.
npm install
npm run build
npm start # runs on port 3000 (PORT in .env)docker build -t filahub .
docker run -d --name filahub \
--env-file .env \
-p 3000:3000 \
--restart unless-stopped \
filahubPrebuilt images are published to the GitHub Container Registry
(ghcr.io/grimbixcode/filahub) whenever a version tag is pushed.
The PostgreSQL database must be reachable from the container (e.g. via Docker Compose with a Postgres service or an external database server).
docker-compose.yml is a ready-to-use deployment template: the app image from GHCR
plus a PostgreSQL 17 service with a persistent volume.
# 1. prepare configuration (see section 2)
cp .env.example .env # fill in APP_SECRET + Telegram values
# 2. set a database password: replace "change-me" in both places in docker-compose.yml
# 3. start app + database – pending DB migrations are applied automatically
docker compose up -dNotes:
DATABASE_URLfrom.envis overridden bydocker-compose.ymlso the app talks to the bundleddbservice – you can ignore that variable for Compose.- Postgres is published on
127.0.0.1:5432in case you want to inspect the database or run drizzle commands from the host; remove that port mapping if you don't need it. - Updating to a new release:
docker compose pull && docker compose up -d. - Put a reverse proxy with HTTPS in front of port 3000 (see section 5).
The app listens on port 3000. Put a reverse proxy with HTTPS in front of it for your domain. With Caddy:
filahub.yourdomain.at {
reverse_proxy 127.0.0.1:3000
# HTTPS only, including subdomains. Caddy redirects HTTP to HTTPS on its
# own; this tells the browser to stop asking. Start with a short max-age
# while you are still moving things around — the value is hard to take
# back once browsers have seen it.
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
The app sets its own Content-Security-Policy, X-Frame-Options,
Referrer-Policy and Permissions-Policy (see api/app.ts), so the proxy
does not have to. Do not strip or overwrite them.
The same goes for Cache-Control: the app marks its hashed bundles under
/assets/ as immutable and everything else — index.html, the manifest,
version.json — as no-cache. That is what lets a copy installed on a
phone's home screen notice a new release and reload itself. A proxy that
caches HTML in front of the app brings the stale-app problem back.
Caddy sets X-Forwarded-For by default, which is what the sign-in rate limit
keys on. If you have a second proxy in front of Caddy — Cloudflare, say — set
TRUST_PROXY_HOPS=2, otherwise the limit counts the wrong address.
DNS: point an A record of the domain to your server's IP. Caddy obtains the TLS certificate automatically. nginx + Certbot works too, of course.
Outside of localhost the session cookie is
Secure; SameSite=Lax– HTTPS is therefore required in production.
Primary: official Telegram Login Widget (button on the login page)
- Click "Log in with Telegram"
- Telegram opens the official confirmation dialog – your identity is also verified via your registered phone number
- Confirm → logged in
Prerequisite: the domain must be registered via
/setdomainat @BotFather (see step 1). The widget also requires third-party cookies; in browsers with strict blocking, use the alternative below.
Alternative: code login via the bot
- Click the bot link on the login page
- Send
/loginto the bot → receive a 6-digit code (valid for 5 min) - Enter the code on the website → logged in
Only the IDs in TELEGRAM_ALLOWED_IDS can sign in. An empty list means
nobody — set TELEGRAM_OPEN_REGISTRATION=1 if you really want any Telegram
account to be able to register.
That default is deliberate. Open registration makes you the data controller for however many strangers show up, with everything that follows from it — see PRIVACY.md. Nobody should end up there by overlooking a variable.
OWNER_TELEGRAM_ID gets the admin role and keeps it on every sign-in. If it is
unset, the very first account to register becomes admin — but only while a
whitelist is in place. With open registration there is no such shortcut, or the
first stranger to find a fresh instance would take it over.
Administrators maintain a shared catalogue of manufacturers and containers so users can pick one instead of looking up and entering its empty weight themselves. It has four levels:
Manufacturer → series → version → size. A series is a product line (e.g. Panchroma, formerly PolyTerra PLA), a version is a revision of its container (e.g. the switch from a 215 g plastic spool to a 140 g cardboard one, with a validity period), and a size is the container for one net fill weight (500 g / 1 kg / 3 kg) with its empty weight and, for spools, its dimensions. A series can be tagged with material types so the right containers are offered first for PLA, PETG and so on.
Catalogue names carry a translation (nameI18n), so an entry reads "Cardboard
spool (from 2021)" on an English interface and „Kartonspule (ab 2021)" on a
German one. Both have to be maintained in the admin editor.
For users, under Container types (/gebinde):
- Preset catalogue – browse it, hide manufacturers, series, versions or single sizes you don't need (hiding only affects your own selection; containers already assigned to a material stay valid), copy a preset into your own editable container type, or suggest a correction.
- My container types – your own container types, with an action to suggest one for the shared catalogue.
- My suggestions – the status of your suggestions, including the moderator's reason if one was rejected.
For administrators, under Verwaltung:
- Preset-Katalog (
/verwaltung/presets) – maintain all four levels. Entries that are still in use can only be deactivated, not deleted. - Vorschläge (
/verwaltung/vorschlaege) – accept a suggestion (it is applied to the catalogue and becomes visible to everyone) or reject it with a reason. - Nutzer (
/verwaltung/nutzer) – see the accounts on this instance, block and unblock them, and decide unblock requests. See "Abuse protection" below. - Missbrauch (
/verwaltung/missbrauch) – what the abuse protection has turned away in the last hours and days.
Catalogue limits are set for containers, not just for spools: up to 50 kg of content and up to 20 kg empty weight, and the empty weight may exceed the content weight — a 2 kg steel container holding 500 g of test powder is a valid entry. Outer diameter, width and bore are only asked for when the form is a spool.
An instance is no longer one account per stock. An organization owns stores of its own, and every member works on the same stock rather than on a copy. A switcher in the sidebar decides whether a page shows a user's personal stock or an organization's; the two never mix, and a store belonging to an organization cannot be shared with friends.
Membership comes at one of four levels, each including the ones below it:
| Level | May |
|---|---|
| View | see and search the stock, the stores, container types and dryboxes |
| Weigh | also weigh and log consumption |
| Record | also add, change, delete and import material; maintain containers/boxes |
| Manage | also create and delete stores, invite people, assign levels, rename |
People join either through a join code (ORG-…, which can never hand out
"Manage") or through a personal invitation that takes effect once accepted.
There is always at least one administrator; the last one cannot step down or
leave without appointing a successor.
This matters for whoever runs the instance: material recorded in an organization's store stays with the organization when the person who entered it leaves or deletes their account, and it is not part of that person's data export. Their memberships and invitations are. See PRIVACY.md.
Limits per account and organization (contracts/organizations.ts): 3
organizations founded per account, 10 stores per organization, 100 members per
organization. Personal accounts get 5 stores (contracts/materials.ts).
An instance that anyone can sign up to (TELEGRAM_OPEN_REGISTRATION=1) will
eventually meet someone who is not there to track filament. Three mechanisms
keep that from costing you a database, and all three are fixed values in the
code — there is no environment variable to tune, because there are no paid
tiers to tune them for.
Rate limits (api/middleware.ts). Counted per account for anything signed
in and per IP address for the sign-in itself. Every signed-in procedure carries
a generous baseline; creating material, weighing, logging consumption,
importing, searching and
sending catalogue suggestions carry tighter ones.
Upper bounds (contracts/limits.ts). Per store: 1000 materials. Per
material: 1000 weigh-ins and 1000 consumptions. Per scope (personal or
organization): 200 own colours,
100 own finishes, 100 own container types, 100 dryboxes. Per account: 20 open
catalogue suggestions and 50 per day. None of these is enforced by the database —
two simultaneous requests can exceed any of them by one. The numbers sit far
above what a person reaches; if your users meet one, the value is wrong and the
project would like to hear about it.
Registration limits. With open registration, 20 new accounts per day across
the whole instance and 3 per IP address per day. Neither applies when
TELEGRAM_ALLOWED_IDS is set — there you decide about every single account
anyway. Both apply only to new accounts; signing in to an existing one is
never turned away.
Everything that is turned away is written to the audit log — and nothing that is
allowed, so the numbers on /verwaltung/missbrauch are never ordinary traffic.
When a threshold is crossed, every administrator gets a Telegram message, at
most once every six hours per threshold. That check runs every 15 minutes and
only with NODE_ENV=production.
Under Verwaltung → Nutzer you can block an account. It keeps its entire stock, all its sessions end, and the person gets a Telegram message naming the reason.
A blocked account can still sign in. What it reaches is a page saying it is blocked, from which three things remain possible: download all data (Art. 15/20 GDPR), delete the account (Art. 17), and request that the block be lifted. The first two are not optional for you to keep — data subject rights do not depend on good behaviour, and the code enforces this rather than leaving it to the person doing the blocking.
Requests appear under Verwaltung → Nutzer; approving one lifts the block. Rejecting one requires a reason, which is sent to the person. There is at most one open request per account.
You cannot block yourself or another administrator — an instance that locks out its own administration has no way back except a manual database edit. Remove the role first if you really mean it.
| Command | Purpose |
|---|---|
npm run dev |
Dev server with HMR |
npm run check |
TypeScript check |
npm run build |
Production build to dist/ |
npm run lint |
ESLint |
npm run test |
Vitest (no database needed) |
npm run test:integration |
Vitest against a real Postgres database (below) |
npm run db:push |
Sync schema changes to the database |
npm run db:seed |
Seed the preset catalogue (idempotent) |
npm run test runs the unit tests, which need no database. The integration
tests run the real chain – migrations, seeding, tRPC routers – against
PostgreSQL 17, the same version as in docker-compose.yml.
docker run -d --name filahub-test-db -p 127.0.0.1:5433:5432 \
-e POSTGRES_DB=filahub_test -e POSTGRES_USER=filahub \
-e POSTGRES_PASSWORD=filahub postgres:17-alpine
TEST_DATABASE_URL='postgres://filahub:filahub@127.0.0.1:5433/filahub_test' \
npm run test:integrationEvery run drops the whole schema of the target database and re-applies the migrations. The connection therefore comes from
TEST_DATABASE_URLonly and must differ fromDATABASE_URL– use a dedicated test database.
- Push to
mainand pull requests: TypeScript check, unit tests and the integration tests against apostgres:17-alpineservice container. - Push to
main: the Docker image is built (without pushing) to verify the build works. - Tag push (
v*): the image is built and pushed to GHCR, tagged with the version andlatest.
The empty weights in the starter catalogue (db/presets/catalog.ts) are not
our own measurements. They come from work other people did and share:
- SpoolmanDB (MIT) – the filament database behind Spoolman. It records the empty weight per product line and spool size and is kept current by its contributors. This is the main source for the catalogue.
- SpoolVault – Empty Spool Weights by Brand – a cross-check per brand and spool material.
- MatterHackers – Empty Spool Weights – a long-standing reference list, useful for older spools.
Manufacturers change spools without saying so, and cardboard spools take on water, so treat every catalogue value as a starting point rather than a measurement of the spool in your hand. Weigh the empty container once and the number is yours – that is what your own container types are for.
Found a vulnerability? Please report it privately – see SECURITY.md. Don't open a public issue for it.
The moment someone other than you signs in, you are the data controller for their data — not the author of this software.
- PRIVACY.md — what the app stores, who else receives data, which variables you must set, and how the built-in export and erasure work.
- COMPLIANCE.md — where the project stands on the Cyber Resilience Act and the rest, plus templates for a record of processing activities and the technical measures.
Users of your instance see an imprint at /impressum, a privacy policy at
/datenschutz and terms at /nutzungsbedingungen, filled from your
LEGAL_OPERATOR_* configuration. Without it, those pages say so.
filahub is free software under the GNU Affero General Public License, version 3 or later (LICENSE).
You may run, study, modify and redistribute it. If you distribute it, or run a modified version and let other people use it over a network, you have to offer those people the corresponding source under the same license (AGPL § 13). Running an unmodified copy for yourself carries no such obligation.
The software comes with no warranty and no liability to the extent permitted by law (AGPL §§ 15–17).