A tattoo artist puts a link in their Instagram story. It points at their booking page on TheBest.Ink , the platform I build: the page that shows their work and carries the form a client fills in to ask them for an appointment. The story runs for twenty four hours and then it is gone. Did anybody click it?
That is the entire question. Not sessions, not bounce rate, not a funnel. An artist wants to know whether the thing they just did in the app they actually use sent anyone to that page, and whether anyone finds them on TheBest.Ink without Instagram's help at all.
I built a views dashboard for that. It is smaller than anything I would have built two years ago, and the interesting part is why.
Years ago I wrote my own analytics service, basic_analytics, because I did not want Google Analytics on my sites and I did not want the cookie dialog that comes with handing visitor data to a third party. Django, chart.js, an endpoint that takes a payload per page view, and charts for monthly views, top URLs, browsers, countries, devices.
It works and I still like it, and it is the wrong shape for this problem. It answers "what is happening on my website", which is a question a site owner asks, and the artist is not the site owner: they do not care about my top URLs, they care about their three pages. It also stores an event per view, so the table grows with traffic and the dashboard has to aggregate it on every read. That is the right call for a tool that might later slice by country and device, and paying for it forever is a bad deal for one that never will.
Plausible, Umami, Matomo, PostHog, Fathom, GA4. They are good, several are self hostable, and I discarded all of them.
Every one is built for the person who owns the site, so making one into a per-artist dashboard means mapping each artist onto whatever tenancy concept the tool has, keeping that mapping in sync as artists and studios come and go, and still proving on every request that this artist may see this data. The authorisation problem, the only genuinely delicate part, stays entirely mine. The tool just becomes a second system that has to be up for my dashboard to render.
Here is the actual reason, and it would have sounded like arrogance a few years ago. Writing the boring three hundred lines myself has gotten much cheaper, and taking on a dependency has not. The cost of a dependency is not the integration, it is the years afterwards: the upgrade that changes its schema, the outage you cannot debug, the feature you need that it will not add. What got cheaper is typing out a counter, a classifier, a serializer and a chart, with the tests and the three translations. So "build it yourself" moved from romantic to boring, and boring is a good reason.
That only holds if you also refuse to build the hundred metrics. The point was never that I could write Plausible. It was that I did not have to.
One row per page, per day, per channel, holding a running count.
page_type artist | studio | booking
artist FK, set for artist and booking pages
studio FK, set for studio pages
date the day
source direct | internal | search | instagram | facebook | tiktok | ...
count a running integer
A view increments today's row instead of inserting a new one, so the table grows with pages and days, not with traffic. Ten thousand views on a profile in one day add a handful of rows, one per channel it was found through.
One detail of that schema cost me an afternoon: Postgres does not treat NULL as equal to NULL, so a unique constraint over a nullable foreign key deduplicates nothing, and the artist rows and studio rows each need their own partial unique index.
The obvious way to count a page view is to count it while rendering the page. That does not work here, and finding out why was most of the actual work.
The public pages are served from a cache: one in Next.js over the data fetch, and a per-object cache in the Django API underneath it. That layering is good for the pages and fatal for counting, because a cached render does not run. Counting inside the data fetching layer counts cache misses, so the reported number would be views multiplied by the cache miss ratio. It would look plausible, be wrong by a factor nobody can see, and move whenever I tuned the cache.
So the count comes from the browser. Each page renders a tiny client component whose only
job is to send one small request after it mounts, saying "this page was just opened", to
an endpoint that counts it, with a ref guarding against React's Strict Mode double effect.
That request never throws, uses keepalive so it survives a visitor navigating away
immediately, and swallows every failure. Analytics must never break the page it is
measuring.
The referrer has the same problem one level deeper. The natural place to read where a
visitor came from is the Referer header, and it does not exist here: the page came out
of a cache, so there is no per-visitor request to read it from, and the view report's own
Referer says my page, because my page is what sent it. The only place the information
exists is document.referrer, so the view report carries it.
Which creates the next problem. The endpoint is unauthenticated, because every visitor to a public page hits it, so a referrer stored verbatim would let anyone mint unbounded rows by varying it. The column is a fixed vocabulary of channels instead, and the referrer is folded onto it server side, which caps the fan out at one row per page, per day, per channel no matter what anyone sends.
Folding it server side rather than in the frontends pays off immediately, because the
classifier is wrong the first time in ways you only learn in production. Instagram sends
visitors through l.instagram.com, so the bare domain never appears. LinkedIn rewrites
every outbound link to lnkd.in. Google and Pinterest run a domain per country, so
matching a host list is wrong the day somebody visits from a locale nobody thought of.
Every one of those was a correction I shipped without touching a frontend. Nothing is ever
rejected, either: an unparseable referrer counts as direct and an unknown host as other
website, because a view is worth counting even when its origin is a mystery.
Every view of a page is one more increment on the same row, today's row for that page. So the endpoint that is called more often than any other one I have, and matters least, has all of its callers changing the same row at the same time. Both of those had to be designed for.
The database has ATOMIC_REQUESTS on globally and this view opts out, so autocommit
releases that row the moment its UPDATE finishes instead of holding it until the
response is rendered. The write is an UPDATE ... count = count + 1, and only if that
touched no rows does it insert, inside a small atomic block, catching the integrity error
from losing that race and incrementing instead.
The obvious next move is to not touch Postgres at all: buffer the increments in Redis, one key per page per day per channel, and flush them into the table on a schedule. Redis is already running here for throttling and as the Celery broker, so it is not even a new dependency, and it is the standard answer for a counter written this often.
I did not build it. A buffer adds a second store that has to agree with the first, a flush job that can fail quietly, and a window in which a restart loses every count not yet flushed. That last one is the part that bothered me: the whole point of the buffer is to delay the durable write, so losing views on deploy is not a bug in the design, it is the design.
And I had no evidence I needed it. The load test floods the endpoint at 500 view reports per second for thirty seconds, on a laptop, alongside heavy uploads on the booking form. With the uploads idle, one percent of the view reports are turned away at the door by a limit I describe below, and ordinary traffic sits at 2.15 ms at p95. The flood of view reports on its own is a non-event. What actually degrades the server is ten image uploads in one request, which has nothing to do with this table.
The same test removes the correctness argument too. It asserts that the views totalled in
the database exactly equal the number of 204s the client got, and exactly is the word:
that is the check that concurrent increments do not lose each other, and incrementing in
the UPDATE is atomic, so it has held at every load tried. A buffer would not have made
the counting more accurate. It would have made it less accurate, in exchange for speed I
did not need. So it is the right fix for a load I do not have, and what matters is the
trigger: when view reports start being turned away while nothing is uploading, the rate of
views has become the problem and Redis is the answer.
The paths that count nothing matter more than the path that counts. An off switch for the whole feature and a bot filter both return before authentication, throttling, body parsing and any database access, because parsing a crawler's body to then discard the result is work with no outcome. Above the application, the reverse proxy lets at most four view reports be handled at the same moment. A fifth arriving while those four are still being answered is refused on the spot with a 429, without waiting and without touching the server behind it. Four is half of the eight requests the server can work on at once, and that is the actual policy: analytics may take half the server and no more. And in the client, the shared request helper retries failed requests, but this one call is pinned to a single attempt, because a 429 is the server asking for less, and retrying it would send more at exactly that moment. A dropped view is the intended outcome there.
That last sentence is the one I would keep if I could keep only one. Almost every decision in this feature follows from being willing to say it out loud.
There are no unique visitors. The dashboard says "Views" and "how often your pages were opened", and that is literally what the number is. If an artist reloads their own profile eleven times, that is eleven views.
I know how I would fix it. Without cookies, which is what the good hosted tools do: hash the IP address and user agent with a salt that rotates daily, keep the hashes in Redis with a one day expiry, and treat a repeat hash as the same visitor. With a cookie it is simpler to reason about and worse to ship, because a first party identifier is not strictly necessary for providing the service, so in the EU it needs a consent dialog, and now every visitor to every artist's page meets a banner to make a number in someone else's dashboard slightly better.
I did neither, and not only because of the banner. Unique visitors breaks the data model.
count = count + 1 cannot deduplicate, so a distinct count means keeping the visitors
themselves, one set per page per day: a key store that grows with traffic, a second store
the dashboard has to read from, and an expiry policy for it. What makes this feature cheap
to own is that it is one small table.
So I was honest about the number instead. The absolute value is inflated. What survives the inflation is the shape: the day the story went up against the days around it, Instagram against direct against search, this week against last. An artist asking "did the story work" is asking for a comparison, and a comparison tolerates a constant factor.
The dashboard shows an area chart per page over seven, thirty or ninety days, a card per page with its total, and a ranked list of where the visitors came from.
The feature is roughly one table, one endpoint, one classifier, one stats builder and one chart. It is not the analytics I would have built if I had asked what analytics should contain. It is what was left after asking what the artist wants to know, and then refusing to build anything else.
The booking form on thebest.ink asks a visitor for the usual things, a description of the tattoo they want, where on the body it goes, how to reach them, and then it asks for photos. Up to five reference images of the style they like, and up to five photos of the body part where it goes. Ten images at most, and any one of them can be up to five megabytes.
For a long time this was one POST. The browser packed everything into a multipart form, sent it to the API, and the API validated the images, wrote them to object storage, and created the row. One request, one response, nothing clever. It worked, which is why it survived as long as it did.
It does not scale, and the reason is more interesting than "uploads are big".
Every byte crossed the server twice. Once on the way in from the visitor's phone, then again on the way out to the bucket. The server did nothing to those bytes in between. It was an expensive piece of wire.
The slow half of that trip is the phone. People fill this form in on mobile data, and a worst case submission is fifty megabytes going up a connection that was never built for it. For the entire length of that upload, one of my worker threads is occupied and doing nothing. It is not computing. It is holding a connection open while a phone trickles bytes through it.
So capacity was never really about requests per second. It was about how many people could be uploading at the same moment, and that number was small. The reverse proxy had to buffer request bodies of up to fifty megabytes, and to keep memory from running out I had to cap how many of those could be in flight at once. That cap was the ceiling on the whole feature. Timeouts had to be long enough to tolerate a bad connection, so one stalled phone held its slot for minutes.
A load test made the ceiling easy to see. The bottleneck was not the database, not the image handling, not anything I could rewrite. It was the decision to put the bytes on the request path at all, and there is no tuning my way out of that. The only fix is to move the bytes.
The browser uploads each image directly to object storage. The API never sees a single byte of image data.
The flow becomes three steps instead of one. The browser asks the API for permission to upload, describing what it wants to send. The API answers with a short lived, narrowly scoped credential per file. The browser uploads straight to the bucket. Then the form submits as it always did, except the request body is now a small JSON document naming the things that were uploaded, rather than carrying them.
A submission that could weigh fifty megabytes now weighs about two kilobytes. The proxy buffering limits and the in flight cap stopped being load bearing. The generous timeout became unnecessary. None of that was an optimisation in the usual sense, where the same work is made to run faster. The work simply stopped happening on that machine.
There is a second win that falls out for free. Ten images used to be one request that failed as a unit, so a single flaky upload at the end of a long form lost everything. Now each image is its own transfer with its own progress and its own retry, and the form submission is a small request that either works or does not. The failure modes got smaller and more honest.
The instinct that makes people nervous about this is correct: it hands an anonymous browser a credential that writes to my bucket. The answer is that the credential is not general. The server decides everything about what that upload is allowed to be, and signs those decisions in.
The server picks the destination, so the holder cannot write anywhere else. The server pins the access level, so nothing lands publicly readable in a bucket that also serves public files. The server pins the content type and the exact size, both of which the browser already knows before it asks. The credential expires in minutes.
The same principle governs the second phase. When the form is finally submitted, the browser sends back a reference to what it uploaded, and the temptation is to let it name a storage location directly. That is the mistake. The server hands out a signed token naming the location, and only accepts that token back. Without it, a visitor can submit a form pointing at somebody else's uploaded files, and the performance improvement has quietly become a data leak.
The rule I ended up with: the client can move bytes, but the client never gets to choose facts. Every fact stays server side, signed, and short lived.
This is not free, and the costs are the part that is missing from most write ups.
The server stops seeing the bytes, so it stops being able to prove anything about them. The old code knew an uploaded file was genuinely an image, because it had the file in hand. Pinning a content type is not the same thing: it constrains what a client claims, not what it sent. That check has to move to a background job after the fact, or be given up. The thing worth getting right is making that an explicit decision rather than something that silently stops happening.
One request became two phases, and phases leak. A visitor picks four images, they upload, and then the visitor closes the tab. Those files are now in the bucket, referenced by nothing, and no code path will ever look at them again. This is not an edge case, it is ordinary behaviour on a form that people abandon. Uploads that are never claimed need an expiry policy, and that policy lives in the storage provider rather than in the application.
I delegate a lot of implementation work to Claude. The best change I made to that workflow was to give Claude its own fully provisioned machine, in addition to the setup I run on my laptop. In my case that machine is a Mac mini that runs the exact same environment my project needs. I still run Claude locally. I also run it on the mini, and sometimes on both at the same time. When Claude works on the mini, it runs against its own database, and I control it remotely from whatever device I have with me.
As a side effect, that same machine became my CI runner. But the agent was the point, so I will start there.
Nothing here needs a Mac mini specifically. I happened to have one that was not being used, and I decided to give it a job. Any spare machine works, and so does a VPS. What matters is that the machine is always on and already provisioned, not what it is or where it lives. If you have a cheap Linux machine that stays on, or a rented VPS, that is your setup. Read "Mac mini" everywhere in this post as "the always-on machine I chose."
My project is geospatial. That means the native GIS stack, GDAL, GEOS, PROJ, and related libraries, plus PostGIS running on Postgres just to run the test suite. None of this is installed by default in common Linux distributions. The setup is not large, but it is specific. The dependencies are tied to particular C library versions and behave differently across distributions, so I cannot assume a fresh machine has what the tests need. The real environment is the only environment.
An agent is only as useful as the environment you give it. If Claude cannot run the migrations, cannot reach a real PostGIS database, and cannot run the tests, then it is guessing. For a stack like this, guessing is worthless. So the requirement was simple. Claude needs a real copy of the environment, fully provisioned and always running. Not a mock, and not a reduced container. The same specific setup I run for myself.
The Mac mini is exactly that. It is a second, complete copy of the environment, and its only job is to be the place where Claude works.
The obvious question is why I did not just point an agent at a git worktree on my laptop. I tried that. Worktrees isolate source code and nothing else. Every worktree shares the same Postgres instance and the same migration history. The moment Claude runs a migration on one branch, it has changed the state for everything else on the machine, including whatever I am doing.
A separate machine isolates the part that worktrees cannot: the stateful part. Claude runs many migrations and test runs against the mini's database, and my laptop, on a different branch, never notices. That is real parallel work on branches that change state, which you cannot get by adding more worktrees on one machine.
Because the environment lives on the mini and not on the machine in front of me, it stops mattering where I sit. I connect to the mini and control Claude remotely with Remote Control. I give it a task from my laptop, check its progress from my phone, or review a diff on an iPad from the sofa. The valuable part, the provisioned environment, stays in one place. The client is only a way to view it, and I can use any device.
This is the nice part of the setup. The work does not live on a laptop that sleeps when I close it, or that travels when I travel. It lives on a machine that is always on, always provisioned, and reachable from anywhere on the network.
Once a fully provisioned machine with the whole GIS and PostGIS stack is there and always on, making it a self-hosted CI runner is almost automatic, and it is a good result.
GitHub-hosted runners are a poor fit for a stack with specific, non-default dependencies. Every run starts from an empty machine, so every run pays to install the whole setup again, billed per minute, on every push. On the mini the dependencies are already installed, Postgres and PostGIS are already running, and the cost is fixed, because it is hardware I already owned. I did not set up the mini for CI. CI was simply a result of having the environment there.
I would not be honest if I stopped there.
Self-hosted runner security. Self-hosted runners on a public repository are genuinely dangerous. A malicious pull request can run any code on your machine. This is only safe for me because I run it against a private repository. If yours is public, do not do this without first reading GitHub's warnings about self-hosted runners.
macOS versus Linux in production. I deploy to Linux, so running the agent and CI on macOS is a real difference in environment, and I will not pretend otherwise. What makes me comfortable with it is that the parts most likely to cause me problems are the same on both sides. It is the same GDAL, GEOS, and PROJ libraries, and the same PostGIS, only built for a different operating system. That is where the geospatial correctness I actually worry about lives. The difference between macOS and Linux sits mostly below that layer, in areas my tests do not check. This is a difference I accept on purpose, not one I have removed. In my experience these libraries behaving differently across operating systems is rarely the source of a bug. If something only appears in production, I would check this last, not first.
Single point of failure. It is one machine on my network. If it is down, or if I am on a bad wifi connection, both the delegated workflow and CI stop working.
Because short-lived containers make me pay the setup cost again on every run. You get slow builds from an empty state, fragile layer caching, and image versions that change unexpectedly. The setup is not large, but it is specific and it is stateful, and neither of those fits a container you throw away after each run. The whole strategy is to set this up once and keep it running. That is the opposite of what a fresh container gives you, and the opposite of what an agent working for an hour needs.
If your project is light, ignore all of this. An agent on a worktree is fine, and a spare machine is too much. But if your environment needs specific dependencies that are not installed by default, or a stateful database, the change that helped me most was to give my AI agent its own provisioned machine, not only run it beside me:
One Mac mini, one provisioned environment, and everything else follows from that.