[Systems, Infrastructure]

An overengineered todo app

A single-user task system built and operated like production infrastructure — self-hosted Postgres, an edge worker, and a daemon that records every work session. This is what it costs, how fast it is, and how it holds up under attack.

Role

Design, build, operate

Scope

Domain modelling

API design

Edge runtime

Local daemon + storage

Stack

React, Vite, TypeScript, TanStack Query

Hono on Cloudflare Workers, Better Auth

Postgres via Hyperdrive, R2, launchd daemon

[What Is In It]

Everything a todo app does not need

Mogee tracks tasks. One user, one login, me. The domain is small on purpose. When the problem is already understood, the engineering is the only thing left to get right.

So the build treats it like a production system. That decision is the whole project, and this is what it produced.

Self-hosted Postgres

Running on hardware I own, patched and backed up by me.

Edge worker

Hono on Cloudflare Workers, serving the SPA and the API from one deployment.

Database-enforced invariants

Generated columns and triggers compute lifecycle state and time rollups. Clients never assert them.

One timer, guaranteed

A database constraint makes two running timers impossible on any device.

Screen recording daemon

launchd on a Mac drives OBS to record each work session.

Object storage pipeline

Multipart upload, 60-day retention, Range-request playback.

A full auth stack

Better Auth, MFA, session management. For one person.

Infrastructure

Five pieces. A Cloudflare Worker running Hono serves both the SPA and the API. Postgres runs on a mini PC at home. Hyperdrive sits between them and pools connections. R2 holds the session recordings. A launchd daemon on a Mac drives OBS and uploads what it captures.

Browser SPA Phone Worker · Hono session gate /api/t/* · /timer/* · /chat Hyperdrive Postgres Mac daemon launchd · OBS daemon API secret-gated R2 recordings, 60d cookie pooled secret multipart reconcile Dashed lane: recording path. Solid lane: the request path a page load depends on.
The worker is the only public surface. The database is not reachable from the internet, and the daemon only ever writes.

Three of those choices were not obvious, so they are worth explaining.

One worker for the SPA and the API. Splitting them means two deployments, two sets of environment variables, and CORS between them. Serving both from one worker removes all three. The tradeoff is that a bad API deploy takes the frontend with it, which for a single-user app is an acceptable failure.

Self-hosted Postgres instead of managed. Managed Postgres for this workload costs between nine and fifteen dollars a month and buys failover I do not need. The box already exists and runs other things. The real cost of this decision is not money, it is that I own patching, backups and restores, and a disk failure is an outage as long as my restore takes.

Hyperdrive rather than a direct connection. Workers have no persistent process, so a naive setup opens a fresh Postgres connection on every request. At any real concurrency that exhausts the connection limit and the database stops answering. Hyperdrive holds the pool outside the worker. This is the single component most responsible for the app staying up under load, and section three has the numbers.

The database is not exposed to the public internet. Administration happens over Tailscale. The worker reaches it through the Hyperdrive binding, so a connection string never exists in client code.

Cost and scalability

Most cloud comparisons price a generic app. That does not help, because the bill depends on which line item your workload leans on. So the first step was measuring the shape of the load.

DimensionMeasuredEffect on cost
Requests~2,400/dayPeak 40/min. Compute is not the cost centre.
Payloads< 8 KBTask rows and JSON. Negligible.
Database size~12k rowsSix tables under active read. Fits in memory.
Recordings written~3.2 GB/mo60-day retention, so about 6.4 GB at rest.
Recordings played back~35 GB/moVideo egress is the bill. Everything else rounds to zero.
The last row is the only one that changes a decision.

That turns the provider question into a single question: what does each one charge to send bytes back out. Here is the same architecture priced on all four at current volume.

Line itemCloudflareAWSGCPAzure
Compute$5.00$0.86$0.00$0.00
Managed Postgres$0.00$14.71$9.37$12.41
NAT / private networking$0.00$32.40$0.00$0.00
Object storage at rest$0.10$0.15$0.16$0.13
Egress on playback$0.00$3.15$4.20$2.98
Monthly total$5.10$51.27$13.73$15.52
35 GB/mo of video egress on each provider. The AWS NAT gateway line is the cost of putting RDS in a private subnet and then talking to it.

At this volume the gap is forty dollars a month and it does not matter. It matters at the next order of magnitude, because egress is the only line that scales linearly with use.

UsersPlayback egress/moR2S3GCS
135 GB$0$3$4
1003.4 TB$0$306$408
1,00034 TB$0$3,060$4,080
10,000340 TB$0$28,900$40,800
Egress only. Every other line stays roughly flat across these rows.

At a thousand users, egress alone is three thousand dollars a month on S3 and nothing on R2. That is the reason the recordings live where they live. It is not a preference for Cloudflare. This workload's dominant cost happens to be the one thing R2 does not charge for, and a workload centred on compute or storage volume would have gone somewhere else.

That projection assumes the rest of the architecture reaches a thousand users. It does not. Three things break first.

What breaksAt roughlyWhy
Single Postgres box~400 concurrentOne machine, no replica, no failover. A disk failure is an outage as long as the restore takes.
Recorder daemon model2 usersIt assumes one Mac running launchd. There is no multi-machine design. This is the least scalable part of the system.
Single-tenant schema2 usersNo tenant column anywhere. Adding one is a migration across every table and every query in the table API.
Where the design stops being true.

What I chose not to buy: read replicas, multi-region, managed database, autoscaling, a standby. Each one costs real money against a failure mode whose worst case is a morning without a task list. The restore path is a manual procedure and it is written down. For this blast radius that is the right amount of engineering.

Performance

One user generates no load worth measuring. So the approach was to measure what exists, then generate load until it broke. The useful number is not the latency. It is the concurrency at which the latency stops being true.

Baseline first, measured from a warm edge location, thirty runs per endpoint, against production data.

Endpointp50p95p99
Task list, 200 rows11 ms29 ms58 ms
Categories8 ms19 ms34 ms
Start timer (write)17 ms41 ms77 ms
Category burn-up chart14 ms33 ms62 ms
Recording seek, 1 MB range22 ms48 ms96 ms
Cold start is 4 ms. Workers run V8 isolates rather than containers; the equivalent Lambda cold start measured around 240 ms.

The burn-up row used to be the worst thing in the app. That chart needs total time spent per category, which is a sum across every session ever recorded against every task in it. Computed on read, that aggregate grows forever.

So it is not computed on read. A trigger maintains actual_seconds on the task row as sessions close. The work moves to write time, where it is a single row update nobody is waiting on.

Approach5k sessions50k sessionsWrite cost
Aggregate on read840 ms7,900 ms—
Trigger-maintained rollup14 ms14 ms+0.4 ms
Denormalisation for read performance. Reads become constant time and writes get slightly slower.

The risk in that trade is real: if the trigger is ever wrong, the number is silently wrong forever. So there is a reconciliation query that recomputes the rollup from scratch and compares it against the stored value.

Then the load test. k6, three-minute ramps, against the read-heavy path a real session generates.

Concurrent sessionsp99ErrorsWhat is happening
10078 ms0.00%Flat.
250141 ms0.00%Still flat.
400412 ms0.18%Connection pool begins queueing.
5502,310 ms4.10%The knee. Pool saturated, requests queue behind it.
700timeout22.4%Workers hit the wall-clock limit waiting for a connection.
The failure mode is not CPU and not the database. It is Hyperdrive pool saturation.

Finding the knee mattered less than finding the cause. The app was issuing four queries per page render where one would do. Collapsing them moved the ceiling without changing any infrastructure.

550 → 900

Concurrent sessions before the knee

2,310 → 190 ms

p99 at 550 concurrent

4 → 1

Queries per page render

Nine hundred concurrent sessions is not impressive in absolute terms. It is roughly two orders of magnitude more headroom than this app will ever use, it cost an afternoon, and the next wall is known: the single Postgres instance, which has no replica to read from.

Security

This app records video of my screen while I work. That footage contains client work, private messages, and credentials that were not meant to be on screen. Everything else in the system is a task list. The threat model is almost entirely about the video.

Scope first, because a threat model that defends against everything defends against nothing. In scope: an opportunistic scanner finding the public endpoint, a stolen laptop running the daemon, a compromised dependency in the build, a leaked session cookie. Out of scope: tenant isolation, because there is one tenant; insider threat, because the insider is me; a targeted state-level adversary.

AssetWorst caseControlResidual risk
Session recordingsDisclosure of everything on screen for 60 daysPrivate bucket, no public read. Playback proxied through a session-gated worker issuing 5-minute signed ranges. 60-day hard expiry.Any live session can view all recordings. Accepted: that is the account owner.
Daemon API tokenAttacker writes junk recordings or reads existing onesWrite-scoped to two recording endpoints. Cannot read task data, cannot list the bucket. 30-day rotation.A stolen laptop can upload garbage until rotation. Detectable, not preventable.
Postgres credentialsTotal data lossNever present in client code. Hyperdrive binding rather than a connection string. Database not exposed to the internet, administered over Tailscale.Low.
The single loginAccount takeover, which means all of the aboveArgon2id, MFA required, 7-day sessions, cookies secure and httpOnly with sameSite strict.Phishing. MFA is the only control standing here.
Task and note contentDisclosureSession-gated table API with an allowlist.Low.
Five assets, ordered by what would actually matter to lose.

The table API needs its own explanation. One handler serves every table through /api/t/:table, which is convenient and is also the kind of endpoint that ends up in someone else's writeup with a CVE attached. It works because of what it refuses.

The table name must match an allowlist of six. Every column named in a filter or sort must appear in that table's own allowlisted column set. Every value is a bound parameter and nothing is interpolated into SQL. Anything outside those rules returns a 400 before reaching the query builder. The auth, session and account tables are not on the list and no input reaches them through this path.

And the controls I decided not to build:

Not builtReason
A WAFIt buys signature matching against injection and traversal classes the allowlist already forecloses. A second, weaker copy of a control already in place.
Rate limiting beyond the platform defaultOne user cannot generate abusive load. This becomes wrong the moment signup is public, and it is the first thing that changes.
Automated secret rotationFour secrets, rotated by hand, procedure written down. Automation here is more moving parts than the thing it protects.
An audit logNo good reason. This one is a real gap. There is no record of who viewed which recording and when, and the recordings are the sensitive asset. It is the next thing being built.
Three of these are deliberate. The fourth is a gap that has not been closed.
[See More Work]

See the next project ↓

[AI Product, Founder]
atlas.finneykoshy.com/companies
Atlas CRM
Quick actions /

Records

People
Companies
Agents
Logs
Projects
Pipeline
Analytics
Finney Koshy

Companies

71
∞ Find companies Show closed (89) + Add
≡ Sort ≡ Filter
Company Stage Industry People Notes Added
OpenAI
Reply received AI / Foundation Models
SA GB
ChatGPT, GPT-4, Codex May 9
Anthropic
Intro booked AI / Foundation Models
DA
Claude, Constitutional AI May 9
Stripe
Message sent Fintech / Payments
PC JD
Online payments infra Apr 21
Vercel
Interviewing Dev tools / Infra
GR
Frontend cloud, Next.js Apr 10
Linear
Reply received Dev tools / PM
KH
Issue tracking for SaaS Apr 21
Figma
Person identified Design / Collab
DF
Collaborative design tool May 9
Notion
Message sent Productivity
IZ
Docs, wikis, projects Apr 10
Airbnb
Message sent Marketplace
BC
Marketplace for stays Apr 21
Shopify
Person identified E-commerce
TL
E-commerce platform May 9
Ramp
Reply received Fintech / Cards
EG KM
Corporate cards & spend Apr 10
Discord
Message sent Comms / Social
JV
Voice, video, chat Apr 21
Spotify
Reply received Consumer / Audio
DE
Music streaming Apr 10
Coinbase
Message sent Crypto / Fintech
BA
Crypto exchange Apr 21
Plaid
Person identified Fintech / Infra
ZP
Banking API infrastructure May 9

Atlas

A career CRM that runs the job search like a sales pipeline.

[AI Infrastructure, Platform Engineering]
app app app dialect A dialect B dialect C app app app Onefold one surface A B C every client learns every dialect every client learns one switching model rewrites four things switching model changes a string The four things: request shape, parameter names, stream protocol, error body.

Onefold

The inference layer under every app I run — routing, translation, metering.