lxc-keyreg/README.md
2026-08-24 15:21:24 +01:00

26 KiB

Church Key Register

Tracks which physical key copy and which NFC alarm tag is held by which person, across several cabinets, with a signed record of every handover.

Node + SQLite in one container. The whole register is a single file.

Print Part 5 and tape it inside the key cabinet.


What it does

  • Board — one peg per piece of metal, grouped by cabinet, in hook order. Filled means it's on its hook, hollow means it's out with someone.
  • Doors — one row per door, showing all its copies and everyone holding one.
  • People — who holds what, and everything they've ever returned.
  • Log — every handover, append-only, with signatures.
  • Journal — cabinet-level events that belong to no single key.
  • Admin at /admin — keys, doors, people, cabinets, CSV import, backup.

Key holders never log in. Only you and your colleague use it.


Part 1 — Install

sudo mkdir -p /opt/keyreg && cd /opt/keyreg
# copy the project files here
mkdir -p data
docker compose up -d --build
docker compose logs -f keyreg

First build takes a few minutes — better-sqlite3 is compiled from source.

Access, via Tailscale

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
sudo tailscale serve --bg 3000
tailscale status          # gives you https://yournas.tailXXXX.ts.net

Install Tailscale on your phone, sign in to the same account, open that address. Nothing is exposed to the internet and no ports are opened.

Real HTTPS is what makes the phone camera and signature pad behave. Over plain http:// on a LAN IP, some browsers restrict things.

Optional passcode

Set ACCESS_CODE in a .env file next to docker-compose.yml for a second layer on top of Tailscale. Leave it empty and there is no login at all, which is a reasonable choice when only your own devices can reach the app.

When it is set, a login screen appears. The cookie stores a hash rather than the passcode itself, and five wrong attempts from one address trigger a one-minute pause — a shared passcode is short enough to be worth guessing otherwise. Failed attempts appear in Admin → Change log.

Want to click around first?

Set SEED_DEMO=1, start it, explore, then wipe from Admin → Import / backup → Clear everything and set it back to 0.


Part 2 — Load your keys

The model is three tables, and getting this right matters more than the app:

Table What it holds
Doors The 48 physical doors. One row each.
Items The ~80 pieces of metal, plus 30 alarm tags. 12-A, 12-B, 12-C.
Handovers Who took which copy, when, and when it came back.

Every physical copy needs its own tag number. Your 48 numbers identify doors; add a letter for each copy. Without this, the register can tell you a key is out but never which one — and then it is no better than paper.

Import

Admin → Importar / cópias, then pick what you are importing. Four separate importers. Load them in this order — keys point at doors and cabinets, so those must exist first:

  1. Cabinets → 2. Doors → 3. People → 4. Keys & tags

How the CSV is read

  • The first row must be the header, naming the columns.
  • Column order does not matter, and names are not case-sensitive (Tag, tag and TAG are the same).
  • Columns you don't need can be left out entirely, or left blank per row.
  • Extra columns the importer doesn't recognise are ignored, so you can paste a wider spreadsheet without cleaning it up first.
  • Comma, semicolon or tab all work as separators — Excel in Portuguese usually exports semicolons, and that is fine.
  • Use quotes around any value containing the separator: 12-A,"Sacristia, porta interior",Igreja
  • Wherever a column is described as yes/no below, these all count as yes: 1, sim, s, yes, y, true, x. Anything else, including blank, counts as no.

1. Cabinets

Column Required What it is
id yes Short code, e.g. SAC. Forced to uppercase. This is what the keys file refers to.
name yes Short label for the tab on the board, e.g. Sacristia. Keep it under ~12 characters.
full_name no Full name used in headings, e.g. Armário da sacristia. Defaults to name.
note no Where it is and what opens it. Shown under the tab.
off_site no yes/no. Yes for your home reserve box: keys returned there go back to reserve instead of in cabinet.
sort no Number. Lower comes first on the board. Give the reserve a high number so it sits last. Default 0.
id,name,full_name,note,off_site,sort
SAC,Sacristia,Armário da sacristia,Sacristia atrás da porta,0,1
CASA,Reserva,Conjunto de reserva,Caixa selada fora das instalações,1,20

2. Doors

Column Required What it is
num yes The door number, exactly as on your list. Must be unique.
building yes The grouping the door belongs to, e.g. Igreja. Also the list of areas offered on the public request form, so avoid anything you would not show an outsider.
name yes The door itself, e.g. Sacristia.
lock_group no Doors sharing a key profile get the same code, e.g. B. Lets you see what else a lost key might open. Fill in later if you don't know yet.
notes no Anything about the door itself.
num,building,name,lock_group,notes
1,Igreja,Entrada principal poente,A,
2,Igreja,Sacristia,B,Fechadura dura no inverno

3. People

Column Required What it is
name yes Full name. This is the match key — re-importing the same name updates that person.
dept no Department or group, e.g. Coro.
role no Function, e.g. Diretora do coro.
phone no Useful when chasing an overdue key.
email no Stored only; the app sends no email.
active no yes/no. Blank means yes. Set no for people who no longer hold keys — they stay in old handovers but cannot be given new ones.
name,dept,role,phone
Maria Fonseca,Secretaria,Secretária,910000000
João Ribeiro,Manutenção,Sacristão,

4. Keys & tags

One row per physical piece of metal, not per door.

Column Required What it is
tag yes The number stamped on that copy, e.g. 12-A. Must be unique — this is what makes the register able to say which copy is out.
door yes The door name, matched against the doors you already imported. If no door matches, one is created.
building yes Used together with door to find the right door, since two buildings may both have a Cozinha.
lock_group no Only used if this row has to create a new door. For doors that already exist it is ignored — edit the door instead.
cabinet no The cabinet id, e.g. SAC. Created if it does not exist, but then it gets no proper name, so import cabinets first.
hook no Position in the cabinet, e.g. SAC·R2·05. Sets the order keys appear on the board and on the audit sheet. Blank falls back to tag order.
master no yes/no. Marks it as a master key on the board.
status no in (default), out, reserve, lost, retired. Use reserve for copies in the off-site box. Do not import out — issue the key in the app instead, so there is a handover record.
kind no The type code from Admin → Tipos, e.g. key, nfc, cadeado. Defaults to the first type. A code that does not exist is refused, and the row is listed as skipped — create the type first.
notes no Permanent note about this copy. Searchable.
tag,door,building,cabinet,hook,master,status,notes
12-A,Sacristia,Igreja,SAC,SAC·R1·03,,in,
12-B,Sacristia,Igreja,SEC,SEC·R2·01,,in,Cópia guardada de propósito na secretaria
12-R,Sacristia,Igreja,CASA,CASA·12,,reserve,

Re-running an import

All four are safe to run again. Cabinets match on id, doors on num, people on name (ignoring capitals) — so a corrected spreadsheet updates those records instead of creating duplicates. Keys match on tag and are skipped if the tag already exists, so you can never end up with two records for one piece of metal. The result box tells you how many were created, updated and skipped.

There is a useful distinction between a missing column and an empty cell:

  • A column you leave out is not touched. Re-importing just name,role updates everyone's role and leaves phone and department alone.
  • A column that is present but blank on that row clears the field. That is how you empty something on purpose.

So to fix one field across many people, export, delete every column except name and the one you are correcting, and re-import. Nothing else changes.

To change a key that already exists, edit it in Admin → Chaves e etiquetas.

Types

Out of the box there are two: Chave (circle, green) and Etiqueta de alarme (rounded, violet). Add your own in Admin → Tipos — padlocks, gate remotes, fobs, meter-cupboard keys, whatever you keep on those hooks.

Each type has:

  • a code, used by the kind column on import. Lowercase, no spaces. Do not change it once keys exist with it.
  • a name, shown everywhere. Change it whenever you like.
  • a shape — circle, rounded or square — and a colour.

The board reads shape as what a thing is and fill as where it is, so give each type a distinct shape rather than relying on colour alone. Types appear automatically as filters on the board and in the legend.

A type cannot be deleted while any item uses it, and you cannot delete the last one.

Cabinets

Create them first (Admin → Cabinets), because import references them by code.

Board order controls where a cabinet appears on the board and in reports. Lower numbers come first. Give the reserve store a high number — 20, say — so it always sits at the end, after the cabinets you use daily.

Within a cabinet, keys are shown in hook order, using the hook field. Fill it in and the board reads down the cabinet exactly as the hooks do, which is what makes the audit sheet countable. Leave it blank and keys fall back to tag order.

Tick off site for your home reserve box. Keys returning to an off-site store go back to reserve rather than in cabinet, so they stay out of everyday circulation. Issuing one writes itself into the journal.

Two physical rules

  • Label each key with the number only. 12-A, never "Sacristy". A lost key labelled with its door is an invitation.
  • Never store a cabinet's own key inside that cabinet. Cross-store them, and keep a reserve copy of each off site.

Part 3 — Everyday use

  • Giving out a key: Board or Doors → tap the key → Issue → pick the person → set a return date if it's temporary → they sign on your phone.
  • Taking it back: tap the key → Take it back.
  • Who has what: People tab, or Doors for a whole door at once.
  • Overdue: the Overdue filter on the Board. Check it monthly — nothing emails you.

Most church keys are held permanently, so "no return date" is the default. Only set a date for genuinely temporary loans.


Part 4 — Printed reports

The printer icon in the header, or /report. Everything renders as a plain web page, so your browser's own Print → Save as PDF produces the file. Nothing to install, nothing to keep updated.

Report Use it for
Keys currently issued Grouped by person. The one to print for the council.
Overdue keys Only what is past its return date.
Receipt per person What that person holds, with their captured signatures, and a line to sign. Print when someone takes on a permanent set.
Full inventory Every key by cabinet. A copy in the folder, and proof of the whole register on paper.
Audit sheet A tick-box list per cabinet, in hook order. Take it to the cabinet and count along.

The audit sheet is the one to use for the monthly check. It marks which keys should be absent, so a missing key is obvious rather than something you have to remember.

Reports link deeply: /report?view=person&id=3 goes straight to one person's receipt. There is a shortcut on each person's card in the app.


Part 5 — Access requests (/pedido)

A form where someone asks for access. It creates nothing — no person, no key, no handover. It files a request and writes a line in the journal. You still decide.

Requests appear at the top of the Journal tab, with a count on the tab so you notice them. Handle one by writing what you decided; it stays on record.

Checking whether the form is actually protected

The Journal tab shows the live state of the form, read from the running server rather than from what you think is in .env:

  • Formulário só acessível a quem entra na aplicação — staff only.
  • Formulário público, protegido por código — public, PIN required.
  • FORMULÁRIO PÚBLICO SEM CÓDIGO — in red. Public with no PIN, which means anyone who finds the address can post. Set REQUEST_PIN and restart.

The server prints the same thing on startup, so docker compose logs keyreg tells you too. The PIN field only appears on the form when a PIN is configured — so if you open /pedido and see no PIN box, there is no PIN.

Handing your phone to someone

The ✚ icon in the app header opens the form in hand-over mode (/pedido?kiosk=1). Give the person your phone, they fill it in, and the confirmation says "Pode devolver o telemóvel" with a button for a fresh blank form if two people are waiting.

No PIN is asked for in this case. The PIN exists to stop strangers on the internet, and you are standing next to them — so a request submitted from inside the app skips it. The journal records which way it came, Formulário (no local) or Formulário (online), so months later you can tell whether you were there.

One caveat, and it is a real one. The form page has no link back into the app, but the browser's back button still works. If you hand over an unlocked phone, that person can return to the register. For a visitor you do not know, use Guided Access on iOS or Screen Pinning on Android to lock the phone to that one screen first. For a colleague, it does not matter.

It is staff-only until you say otherwise

PUBLIC_REQUESTS=0 is the default, which means /pedido sits behind the same passcode as the rest of the app. Useful as a tablet form in the office, but nobody outside can submit.

Setting PUBLIC_REQUESTS=1 makes /pedido and its two endpoints reachable without the passcode. That is the only part of this system an outsider can reach, so understand what you are opening:

  • Set REQUEST_PIN as well. Without it, anything that finds the URL can post.
  • The PIN is shared, so treat it as semi-public — print it in the church bulletin if you like, and change it when it spreads too far.
  • Five wrong PINs from one address pause that address for five minutes. A hidden field catches bots that fill in every input; their submissions are silently discarded rather than rejected, so they get no feedback to learn from.

What the form deliberately does not show

It offers areas — your building names — never door names. A public page listing "Sacristia, Torre sineira, Casa das caldeiras" is a map of the building for anyone who finds it. The /api/request/areas endpoint returns building names only.

Check what your building names actually are before opening this up: they come from the building field on your doors, so if you have a building called "Armários" it will be offered as a requestable area. Rename anything that reads oddly to an outsider.

Behind a reverse proxy

Set TRUST_PROXY=1 (or 2 for Cloudflare in front of Nginx Proxy Manager). Without it every request looks like it came from the proxy, so all rate limits share one bucket and five wrong attempts lock out everyone. Leave it empty when nothing proxies the app — trusting a forwarded header nobody sets lets a client forge their own address and dodge the limits.

Reaching it from outside

Tailscale keeps everything private, which is the point — so publishing /pedido needs a deliberate second path. A Cloudflare Tunnel restricted to that one path is the usual approach. Whatever you choose, confirm that /, /admin and /api/state are not reachable by the same route before you share the link.


Part 6 — Backups

chmod +x backup.sh
# edit BACKUP_DIR and OFFSITE_DEST at the top
sudo ./backup.sh          # run once by hand
sudo crontab -e
# 20 3 * * *  /opt/keyreg/backup.sh >> /var/log/keyreg-backup.log 2>&1

It writes a verified SQLite snapshot and a CSV. The CSV matters: if the app is ever gone, a spreadsheet still tells you who holds which key.

Do a test restore before you trust it. The instructions are at the bottom of backup.sh and amount to copying one file back.


Part 7 — Handover to the next person

Fill this in and keep it current:

  • Machine: ________________________
  • Address: https://________________.ts.net
  • Tailscale account: ________________________
  • Passcode, if set, is in: ________________________
  • Backups run at 03:20 to: ________________________
  • Off-site copy at: ________________________
  • Technical help: ________________________

Monthly, five minutes:

  1. Board → Overdue filter. Chase anything red.
  2. Check /var/log/keyreg-backup.log for failures.
  3. Print the audit sheet for one cabinet and count along the hooks.

Yearly: full audit of every cabinet, and confirm the reserve box is still sealed. Write it in the Journal.

On handover: add their device to Tailscale, walk them through one real handover and one restore, then remove your own access.


Design decisions worth keeping

  • Nothing is ever deleted. People are deactivated, keys are retired. Both appear in old handovers, and deleting them would make that history anonymous — which is exactly what the register exists to prevent.
  • Handovers are append-only. Returning fills in a date; it never erases.
  • Cabinet and building are separate fields, because the office cabinet can hold spare masters for the church.
  • Admin changes are logged in Admin → Change log. With two of you editing, "who moved 12-B and when" comes up eventually.

Part 8 — Running it on a VPS

Works well: the app is small, and a VPS gives you a real hostname, automatic certificates and uptime independent of the church building. But the security assumptions change, because the login page now faces the internet.

Non-negotiable once it is public

  1. Set ACCESS_CODE. On a private network, running with no login was a defensible choice. On a VPS it is not.
  2. Set TRUST_PROXY=1, or all rate limiting collapses into one bucket.
  3. Add Basic auth in NPM (Access Lists) as a second, independent layer, so bots never reach the application at all. Skip this only if you need /pedido publicly reachable — in which case rely on the app's own gate, which already allows exactly the request paths and nothing else.
  4. Force SSL, HTTP/2 and HSTS on the proxy host.
  5. Pull backups off the VPS. Set OFFSITE_DEST in backup.sh. If the server dies or the account is suspended, whatever is only on that disk is gone.

Do not publish the port

If NPM runs in Docker on the same host, delete the ports: block from docker-compose.yml and join NPM's network instead (both are commented in the file). Point the proxy host at keyreg port 3000. Nothing then listens on any public interface.

Confirm it afterwards:

ss -lntp | grep 3000        # should print nothing
curl -sS http://YOUR_VPS_IP:3000/health   # should refuse to connect

Choosing how to protect it

Three workable shapes, in order of how much they expose:

1. Nothing public. Run Tailscale on the VPS as well and keep the app private, exactly as it would be at home. You still get the VPS's uptime and no dependency on the church building. Choose this unless you actually need outsiders to submit requests.

2. Basic auth over everything. An NPM Access List puts a browser password prompt in front of the whole host, and ACCESS_CODE sits behind it — two independent layers, and bots never reach the application at all. Simple: no path rules to maintain. The cost is that /pedido is behind the prompt too, so only people with that password can submit a request. Fine when you are always the one holding the phone.

3. Public request form, everything else closed. Do not add an Access List. Set ACCESS_CODE, REQUEST_PIN and PUBLIC_REQUESTS=1, and rely on the app's own gate: it allows exactly /pedido, /api/request, /api/request/areas, /styles.css and /i18n.js, and returns the login screen or 401 for everything else. That whitelist lives in the code, so it cannot drift out of step with nginx location blocks the day a route is added.

A neat hybrid, if you want both: Tailscale for the app and NPM publishing only /pedido.

Nginx Proxy Manager settings

On the proxy host: Block Common Exploits, Websockets off (unused), and under SSL: Force SSL, HTTP/2, HSTS enabled.

Forward to keyreg port 3000 over the shared Docker network — not to 127.0.0.1, which would not resolve from inside the NPM container.

Basic auth has no lockout

NPM will not rate-limit it. If you use an Access List, run fail2ban on the VPS watching NPM's log for repeated 401s.

One thing you lose

When the VPS or your connection is down, you cannot look up a key while standing at the cabinet. Print the full inventory report and keep it in the cabinet — worth doing regardless, and the reason the report exists.


Part 9 — Git and the Forgejo registry

Push the code

cd /path/to/keyreg
git init
git add .
git commit -m "Registo de chaves"
git branch -M main
git remote add origin https://git.plascloud.pt/LuisL/lxc-keyreg.git
git push -u origin main

Check git status before the first commit. .gitignore keeps data/, any .db file and .env out — a repository gets cloned and mirrored, and a list of every door with its key holders is the last thing that should travel. If git status shows .env or anything under data/, stop and fix it first.

Once a secret has been committed, removing it in a later commit is not enough — it stays in the history. Change the value instead.

Build and publish the image

Create a token in Forgejo: Settings → Applications → Generate token, with the write:package scope. Then:

docker login git.plascloud.pt          # username LuisL, password = the token

docker buildx build \
  --platform linux/amd64 \
  -t git.plascloud.pt/luisl/lxc-keyreg:1.0 \
  -t git.plascloud.pt/luisl/lxc-keyreg:latest \
  --push .

The owner part of the image name must be lowercase, whatever case your username uses.

Pick the platform to match the VPS, not your laptop. A Mac builds arm64 by default and that image will not start on an x86 VPS. uname -m on the VPS tells you: x86_64 → linux/amd64, aarch64 → linux/arm64.

Run it on the VPS

Only two files are needed there — docker-compose.yml and .env:

docker login git.plascloud.pt
echo "IMAGE=git.plascloud.pt/luisl/lxc-keyreg:1.0" >> .env
docker compose pull
docker compose up -d

Delete the build: . line from docker-compose.yml once you are pulling a published image, so a stray --build cannot rebuild from a stale checkout.

Upgrading, and going back

./backup.sh                     # always first
# bump IMAGE to the new tag in .env
docker compose pull && docker compose up -d

Tag every build with a real version. If 1.1 misbehaves, putting 1.0 back in .env and running docker compose up -d restores the previous image in seconds. latest gives you nothing to go back to.

Simpler, if you would rather skip the registry

The VPS can build it itself:

git clone https://git.plascloud.pt/LuisL/lxc-keyreg.git keyreg
cd keyreg && cp .env.example .env   # edit it
docker compose up -d --build

No registry, no token, no cross-architecture problem — it builds for whatever the VPS is. git pull && docker compose up -d --build to upgrade. The registry is worth it once you are running it in more than one place.

Automating it

.forgejo/workflows/build.yml builds and publishes on every v* tag, if you have a Forgejo Actions runner. No runner, no problem — the file is inert.


Part 10 — Building once, running anywhere

The image is self-contained: source, dependencies and static files are all inside it. You can build it once, push it to a registry, and run it on the NAS without ever copying the source there.

What is not in the image

.dockerignore keeps three things out, deliberately:

  • data/ and any .db file. Baking the register into an image would ship a list of every door in the building and who holds each key — to whatever registry you push to. Never remove this rule.
  • .env. Your passcode.
  • node_modules/. Rebuilt inside the image, because a native module built on your Mac will not run on the NAS.

Build and push

Your Mac is almost certainly ARM64. If the NAS is x86, an image built plainly on the Mac will not start there. Build for both:

docker buildx create --use --name keyreg-builder     # once
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t ghcr.io/YOURNAME/keyreg:1.0 \
  -t ghcr.io/YOURNAME/keyreg:latest \
  --push .

For a private registry, log in first with docker login ghcr.io.

Run from the registry

On the NAS, you need only docker-compose.yml:

echo "IMAGE=ghcr.io/YOURNAME/keyreg:1.0" >> .env
docker compose pull
docker compose up -d

Delete the build: . line there and the source tree is never needed.

Version your tags

Tag every build with a real version, not just latest. When an upgrade misbehaves, IMAGE=ghcr.io/YOURNAME/keyreg:1.0 docker compose up -d rolls you back in seconds. latest gives you nothing to roll back to.

Upgrading

./backup.sh                    # always first
docker compose pull
docker compose up -d

The database lives in a Docker volume, not in the image, so it survives every upgrade untouched.