The Need: Other People Wanted It Too

A few days after I put the church directory into my own Bible app, people at church heard about it. They asked if everyone could use it. A photo directory is only useful if the other members have it too, and a printed booklet gets lost.

But the directory has photos, names and home addresses of about 140 families. I could not just put it on the internet. And I did not want to make 140 accounts or remember who forgot a password.

So the need had four parts:

  • Members open it on their phones. No account, no sign-up.
  • Only people from our church can get in.
  • Everyone writes private notes, and nobody else can read them.
  • It must not slow down my NAS when many people open it on Sunday.

The Proof: a Separate Site

It is now a separate site, not a tab in my Bible app. My own app stays as it was, so my notes and my habits do not change.

What works today:

  • A login screen with one field and a confirm button. The password is a 4-digit number that every member of our church already knows. The church changes that number every few months, so there is nothing new to remember.
  • After login: 141 families, 377 people, photos, addresses, and one search box that finds a name, a birth year, an address or note text. A person’s photo sits to the left of the name.
  • Notes for each family and each person. They are saved only in the phone’s browser. If you change phones, you export a backup file and import it on the new phone.
  • If you do not touch the screen for 30 minutes, you are logged out. A small warning appears one minute before.
  • When the church changes the number, I edit one file. Everyone who was logged in is logged out the next time their phone checks in.

On a test copy on my PC, asking for the list or a photo without a login returned 401, and a changed code logged out the old cookie. I have not yet repeated those checks on the live site, and I did not measure memory use on the NAS yet. The three containers have memory limits of 64 MB, 64 MB and 96 MB, so the worst case is 224 MB. I will report the real number when I have it.

The Story: Why a Number Everyone Already Knows

I thought about a normal login system first. Then I thought about who would use it: grandmothers, deacons, people who do not remember passwords. A user table would be more work for me and more trouble for them.

A number that every member already knows solves it in a natural way. Someone who leaves the church does not get the new number. A shared number is not strong security, and I will say that clearly: anyone who knows it can come in. For a church directory, that is the level of trust we already have. It is not a bank.

The part I did not expect was the question of what happens when the number changes. If the login is just a cookie that says “yes”, old members keep access forever. So the cookie is signed with a key that is mixed with the current number. When the number changes, the old cookies stop working. Nobody has to be removed one by one.

The How: Three Small Containers

I describe the idea and the numbers. I do not publish the real code or the data.

Layout. nginx:alpine serves static files. A tiny Node script (no npm packages) checks the code and sets a cookie. cloudflared is the tunnel to the internet. Each has a mem_limit in docker-compose.yml. The app is plain HTML and JavaScript, with no framework. Search and sorting happen in the phone, so the server has no search API.

Protecting the private files. The pages and scripts are public because they contain no personal data. The list (/data/index.json) and the photos are behind nginx auth_request. nginx asks the Node script “is this cookie valid?” for every request to /data/.

🗂 Claude.md Rule In nginx, write the protected folder as location ^~ /data/. Without ^~, a regex location such as \.json$ can match first and skip the auth check.

Do not let Cloudflare cache the photos. My first idea was to let Cloudflare’s edge cache the photos to save my NAS. That would be a leak: the edge would serve the photo to anyone, with no cookie. The fix is one header, Cache-Control: private. The browser and a service worker cache the photos, and the edge does not. A repeat visit then costs the NAS almost nothing.

Cookie and idle logout. The cookie holds a timestamp and an HMAC signature. The server rejects it if the last activity is older than the idle limit (30 minutes by default, in a config file). The phone sends a small “I am still here” request at most every two minutes, and only after a real tap, key press or scroll. When the user logs out, the phone deletes the cached list and photos but keeps the notes.

Brute force. A 4-digit number has only 10,000 possibilities, so I added limits. (Later I made the code a little longer than the number members know and replaced the automatic submit with a confirm button.) Ten wrong tries from one IP in ten minutes returns 429. nginx limit_req slows the login URL. If the whole site gets 100 wrong tries in ten minutes, every try waits three seconds. The real visitor IP comes from the CF-Connecting-IP header, because behind a tunnel every request looks like it comes from one place.

Notes on the phone. IndexedDB, one record per family or person, saved 0.8 seconds after typing stops. Browsers can delete this data if the user clears site data, so the settings page has a JSON export and import, and a warning if there has been no backup for 30 days.

Deploying. I created a tunnel and a DNS name with the cloudflared command line, copied the files to a NAS shared folder, and typed docker compose up -d over SSH. Claude Code cannot SSH into the NAS, so that last step was mine.

Cost. $0 extra. No API, no new hardware, no paid service. The domain and the Cloudflare account already existed. From my first prompt to a working site was about three hours, and part of that was me waiting and creating the NAS folder.

💬 Prompt that worked “Our church uses a 4-digit number that all members know, and it changes regularly. I want to use this same number as the password. With it, people should see everything. Each person writes their own notes and sees only their own.”

Later I added one more sentence: “If there is no input for a while, log the user out automatically.” I had not thought of it. It matters, because a phone left on a pew should not show a member list.

Update: What Happens When the Number Changes

I asked myself: if I change the number while someone is typing a note, is the note lost? No.

  • Nobody is thrown out at the moment I change it. Each phone asks the server “am I still valid?” at most every two minutes after a real touch, and when the app opens. The first “no” returns the phone to the login screen.
  • Notes live in the phone’s own storage, not on the server, so they do not depend on being logged in. They are saved 0.8 seconds after typing stops, and a note waiting to be saved is written first when the logout happens.
  • Logout deletes only the cached list and photos. Notes stay, and they are still there after logging in with the new number.
  • The exception is a phone whose browser blocks storage (some private modes). Notes there can vanish when the window closes, and the settings page warns about it.

My advice to myself: before changing the number, remind members to export a backup from the settings page.

Update: An Admin Screen, and a Filter I Removed

Logging in with a separate admin number opens an admin mode. I can add, edit and delete a family or a single person, and replace photos, from the phone. Photos are shrunk in the browser before upload (1000 px wide for a family, 240 px square for a person), so the NAS never stores a 4 MB camera photo. The admin cookie is signed with its own key, so a normal member cookie cannot call the admin API. After the first edit, the NAS copy of the list becomes the master, and my sync tool refuses to overwrite it without a --force flag.

I also built filters for sex and birth year. They felt awkward on a phone, so I removed them and let the one search box match a birth year instead. Typing 1968 is faster than three controls.

Two small bugs worth writing down:

  • The word null appeared on the settings page. DOM replaceChildren(null) inserts the text “null”. Pass an empty string instead.
  • After a deploy, phones kept showing the old screen for hours. Cloudflare had replaced my no-cache header with a four-hour browser cache. The service worker now fetches app files with cache: 'no-cache'.

What Is Still Open

  • I have not measured real memory use yet.
  • A leaked number means a leaked directory. If that happens, I change the number and everyone logs in again.
  • Notes do not sync between phones. That was a choice to keep the server empty, but some people will find it annoying.
  • The admin screen exists now, but I have only tested saving on a copy on my PC. I have not yet saved a real edit and a photo on the live NAS.