People tell AI agents what to build; the agents build, deploy and manage static sites here. This page exists so that changing one of those features never silently breaks a surface nobody remembered.
Simple Host is one Go service, one Postgres database and one directory on disk, behind an nginx edge that fronts the content host sites.simple-host.app (sites by path), every connected custom domain, and the dashboard origin. No CDN, no per-site infrastructure, no third-party hosting. Uploads are static data that can never execute on the server; every version is owned, timestamped and revertible.
Why the map is first. The analytics response shape changed from a flat {views, visitors} to a person / bot / infra / unknown split. One consuming page was updated. A second — the owner Analytics tab, in a different file — was not, and quietly rendered zero for every site. Nothing failed; nothing logged; the owner found it by looking. The fix for that class of bug is not more care, it is a written-down list of every surface a feature touches. Sections 01–03 are that list. Sections 04–10 are the reasoning behind the design.
One card per feature. Open it and you have every file, route, screen, table and document that feature touches — and the line that says what else you must change with it.
The UI row is the one that matters most, and it is the one that gets skipped. It names the file and the tab inside it, because "I updated the dashboard" is exactly the sentence that shipped the analytics bug — there are two dashboards, and a third at /admin.
Each card's Product row links to the plain-language entries on the features page. That is the other half of the same job: this page says where a feature lives, that one says what it promises — and a change usually invalidates both.
#an-{name} — People/Bots/Infra cells + 24 h columns; also feeds the #site-sort ordering, and the same cards render inside the Admin tab#owner-analytics-view — 7/14/30/90-day ranges, #an-totals + #an-body + sparklines. This is the surface that was missed.log_format shanalytics (per vhost) · /var/log/simple-host/analytics.log · /etc/logrotate.d/simple-host-analytics (create + delaycompress) · ANALYTICS_LOG envdaily[].person.views and totals.person.visitors, so renaming a class breaks it silently while index.html keeps working. Then openapi.yaml and operations.md. If the ingest or classifier changed, re-run analytics-rebuild so history matches.ETag header is load-bearing — generated pages poll with If-None-Match and treat 304 as "no change". A PATCH must return the new document. The error body {error, code:"visitor_auth_required"} is matched by string by auth.js and by generated sites. Changing any of it means auth.js, llms.txt, backend.md and openapi.yaml — and it still cannot reach sites already deployed.#data-{name} collection list and #collview-{name}-{coll} row table, plus the CSV download buttonadmin.html viewer that llms.txt requires every generated site to shipidx_collection_items (db/schema.sql){items:[{id, data, created_at}], next} is prescribed to external LLMs — changing it breaks generated sites you will never see. The CSV export reads Content-Disposition in the browser. And deleting a site cascades every row away with no soft delete.#email-form-wrap, #code-form-wrap, #verifying-screen; /v1/me gates all of #main-view and the Admin tab's visibility#ai-auth — the same two endpoints inline, so a build can resume after signing in/v1/me gates the entire signed-in experience on both pages, and they read different fields — index.html needs username and is_admin, showcase.html needs handle. Any change to the auth response also touches register.md, which agents follow literally.purpose forks the callback#oauth-providers/v1/auth/oauth/providers has more consumers than any other auth route — the dashboard, auth.js and every generated site. Adding or renaming a provider changes what all of them render. Owner and visitor flows share the routes: touching one is touching both.GET …/me, and SH.requireSignIn() offers Google or an emailed 6-digit code before the page saves. Custom domain only: on the shared host every site shares one origin, so no visitor session is issued there; writes there are open instead (anyone can change that data), and agents write with an API key anywhere. No first-party page exercises this path.#sites-container and → Admin tab #admin-sites-container#inventory, plus it seeds the Analytics tab's site list#gallery (server-injected, not fetched)GET /v1/sites feeds four tabs across two pages and each reads a different field set — index.html wants site_url, active_version, owner_username, custom_domain, domain_status; showcase.html wants visibility and user_id. Both unwrap the {data:[…]} notice envelope, so the wrapper is part of the contract too.publishGenerated() writes files and reads data.site_urlX-Content-Digest integrity header and the size caps are documented in packaging-and-validation.md, which agents follow step by step.#versions-{name} — expects a bare array[data-versions] — accepts an array or a {data:[…]} envelopeversions.archive_sha256 is written but never read back, so there is no persisted integrity check to rely on.dns.host and dns.value are read straight into the toast the user copies fromshanalytics access-log line or the site records no analytics at all. The skill's promise ("verify by fetching the domain, never poll status") depends on domain_status staying advisory.on is live on simple-host.app (source default log); log and off are operator opt-downs that silently change the meaning of the trust model in §05 and of every "writes are authenticated" sentence in the docs. Change the mode and the wording has to move with it.#ai-create — the chat, #ai-chat + #ai-preview iframe + #attach-strip, with inline sign-in at #ai-auth. This is the only page that calls /v1/generate.#ai-create is a different builder — it asks you to build in your own AI and paste the JSON back, then publishes via the files API. Same name, same look, no shared endpoint.status, reply, html, progress and error are all read by the poller. CLAUDE.md is the reference for the provider setup — there is one provider and no fallback./v1/transcribe/stream is proxied by nginx to a separate local service — it is not a Go route, though CLAUDE.md lists it as one.#chat-mic and #chat-input. A 404 from POST /v1/transcribe is deliberately load-bearing: it is how the page decides to hide the mic button entirely.#public-view / #gallery — rendered from a server-injected blob, not a fetch#owner-view with its Sites and Analytics tabs#showcase-bannerWrap middleware counts every /v1/* request, flushes every 20 s, resolves geo lazily, prunes at 30 days#stats, #users, and #apitraffic with its route and IP tables. Note this is a third dashboard, separate from index.html's Admin tab.Wrap middleware is on the hot path of every API request, so a change here has platform-wide latency reach even though only one page renders it. Route labels are normalised to bound cardinality — adding a path parameter without teaching the normaliser will blow up the table.#copy-llms button that fetches /llms.txt#skill-install for owners/skills/version to set its skill-version headerplugin.json and is echoed by marketplace.json and the SKILL.md header — the three have already drifted apart. Bumping one without the others changes who gets a staleness notice._notice when X-Skill-Version is missing or stale; wraps roughly two dozen routes{data:[…]} on /v1/sites because of this middleware. index.html additionally fetches /skills/version so its own calls are never wrapped.expires_at for anything else.error_page and @notfound blocks are half of this feature; the Go handler alone cannot deliver it. The redirect middleware runs before everything, so a bug here affects every route on the platform.infra class, which is where you want it.The inverse of the map: pick an endpoint, see every screen that would go blank if you changed its shape. Rows marked multi have more than one consumer — those are the ones that bite.
| E1 | GET /v1/sites/{name}/analytics multi | index.html Sites tab cards + Admin tab · showcase.html owner Analytics tab. The two read different subsets — showcase touches only daily[].person.views and totals.person.visitors. This is the pair that already broke. |
| E2 | GET/PATCH …/state multi | auth.js · every LLM-generated site following llms.txt. The ETag header and the visitor_auth_required error code are part of the contract. Widest reach on the platform, and mostly outside our control. |
| E3 | GET /v1/sites multi | index.html Sites tab and Admin tab · showcase.html owner Sites tab, which also seeds the Analytics tab. Four tabs, two pages, different required fields, both unwrapping the notice envelope. |
| E4 | GET /v1/auth/oauth/providers multi | index.html auth view · auth.js · prescribed by llms.txt. Adding or renaming a provider changes all of them. |
| E5 | GET /v1/me multi | index.html gates the whole signed-in app and the Admin tab · showcase.html gates the entire owner console. A field rename locks people out of both. |
| E6 | GET …/versions multi | index.html expects a bare array; showcase.html accepts either shape. Already inconsistent — wrapping this route would break exactly one of them, silently. |
| E7 | POST /v1/auth · /v1/auth/verify multi | index.html auth view and showcase.html inline builder sign-in. |
| E8 | PUT …/active-version · DELETE /v1/sites/{name} multi | Both pages' site lists call these. |
| E9 | POST …/collections/{coll} multi | Every generated site; all of them branch on visitor_auth_required. |
| E10 | GET /v1/analytics/sites one | index.html Sites tab ordering only. A failure degrades the sort silently rather than showing an error — by design, but worth knowing when it looks "fine". |
| E11 | POST /v1/generate · /v1/generate/status one | showcase.html only. index.html's similarly-named builder does not call it. |
| E12 | /v1/transcribe · /ticket · /stream one | showcase.html only. A 404 on the probe is how the mic button hides itself. |
| E13 | PUT …/visibility one | showcase.html owner Sites tab only. |
| E14 | …/domain · …/collections · export.csv one | index.html Sites tab only. |
| E15 | /v1/admin/users · /v1/admin/api-analytics one | admin.html only — a third dashboard, separate from index.html's Admin tab. |
| E16 | GET /openapi.yaml multi | docs.html renders the whole spec through Swagger UI. Every endpoint change surfaces here whether or not anyone updated it — which is why the spec is the source of truth and not a summary. |
Not hypotheticals. Each of these has cost someone real time here.
A response-shape change ripples further than the handler. There is no compile-time link between a Go struct and the JavaScript that reads it, so a renamed field fails by rendering zero, not by throwing. Before changing a shape, read §02 and open every consumer listed. The analytics split is the worked example: two pages, one updated, one silently blank.
The two *_daily analytics tables must never be dropped. Nothing writes to them any more, which makes them look like debris. They are the only surviving record of 2026-07-11 → 2026-08-08 — the raw logs behind that month have rotated away and no rebuild can recover it. The API reads them for every day before classified_from and reports them as unknown. Dropping them silently deletes twenty-nine sites' first month, seventeen of which have no other history at all.
The ingester's inode-and-offset tracking constrains the logrotate config. copytruncate keeps the inode and loses whatever was written between copy and truncate; the ingester needs the inode change to know it should drain the old file. It also cannot read gzip, so delaycompress is what gives it a full cycle to finish .log.1. Those two directives are code dependencies wearing a config's clothes.
Some features are half in nginx. The branded 404, the analytics log and the live-captions WebSocket are each part Go and part edge config. Shipping the Go half and calling it done produces a feature that works on your laptop and not in production. A new custom-domain vhost that omits the shanalytics access-log line yields a site whose analytics are permanently empty, with nothing anywhere reporting a problem.
A migration is not done until the grants are. Migrations once shipped without GRANTs for the service role, so the tables existed and the feature 500ed while everything around it looked healthy. A migration that creates a table must also grant it to the application role, and grants must be verified against the live database after any migration — not just the SQL file.
Static pages are embedded in the binary. Editing anything under internal/handler/static/ changes nothing until the binary is rebuilt and the service restarted. "Deployed" and "visible" are different claims — verify from the client, never from the file you just saved.
Two different builders are both called "Create a site". One calls the model server-side; the other asks you to paste JSON from your own AI. They live on different pages and share no endpoint. Any instruction about "the AI builder" that does not name the page is ambiguous.
The docs-sync script catches less than it appears to. scripts/check-docs-sync.sh hard-fails when a registered /v1 route is missing from openapi.yaml or vice versa, and when an owner route is not auth-wrapped. Both are valuable. But it compares paths, not methods, so adding a verb to a documented path is invisible; it never checks whether prose descriptions still match behaviour; and it has no capability rule for analytics, custom domains, AI create or transcription. It would not have caught anything on this page.
The rule this page is built around: a feature is not changed until every cell in its row is changed. If that is too much work for the change you are making, the change is bigger than you thought — which is exactly the thing worth learning before shipping it, not after.
The defining constraint: user uploads are data, never code — they can never execute on the server. That is what makes agent-generated ("vibe-coded") sites safe to host.
No execution path exists. The server has no interpreter, no SSR runtime, no plugin loader, no per-site processes. Uploaded files are written to disk and later returned as bytes with a content type — a hostile upload has no more power than an email attachment sitting in a folder.
Archives are validated in memory, before anything touches disk. Extraction produces a map of paths to bytes; every guard runs on that map, and only then is a version written. Path traversal is blocked twice over — at extract and again at write — along with absolute paths, control characters, depth over 32 and paths over 1024 bytes. Symlink, hardlink and device entries are dropped rather than followed. Caps: 100 MB per file, 500 MB uncompressed total, 50,000 entries, and a 100 MB request body (MAX_ARCHIVE_MB). Zip bombs are handled by counting bytes actually read, never the declared size in the header.
The extension check is a denylist, and it is a courtesy — not the safety control. Being honest about this matters: only source-script types are hard-rejected (.sh .bash .zsh .fish .bat .cmd .ps1 .py .pyc .rb .pl .go .php). Everything else is accepted, binaries included. Secret-shaped files (.env, .htpasswd, .npmrc, .netrc, id_rsa, anything under .git/ or .ssh/) are silently skipped, not rejected — the deploy still succeeds, minus the secret. The real guarantee is 4.1: nothing on disk can run.
Serving is rooted per site, by nginx, off disk. Static bytes never pass through the Go process. nginx maps /<handle>/<site>/… straight to that site's current/ directory, with the handle and site captured by regexes limited to [a-z0-9-] — traversal is not expressible in them. Directory listings are off (nginx defaults to autoindex off, and nothing turns it on); a directory with no index serves nothing. The Go path checks are real but they are write-time controls: they decide where bytes land, not what gets served.
Versions are per-upload, owned, timestamped and revertible — but "atomic symlink" would be a lie. Each upload writes a new vN/ and a versions row carrying the owner, a timestamp and a SHA-256 of the content. Going live copies that tree to current/ via remove-then-rename: current is a real directory, not a symlink, and there is a brief window during the swap where it is absent. Rollback re-points it the same way. Small, real, and worth knowing before you promise zero-downtime deploys.
Top to bottom: an agent authenticates with the user's key and uploads; extraction happens in memory and the validation gate enforces size caps, traversal protection and the script-type refusal there — before anything touches disk. Accepted content is written as a new vN/ and copied into current/. Serving is a wholly separate read path that never enters the Go process: nginx matches a constrained path regex and returns bytes. No user code ever runs server-side — that is the guarantee, not the extension check.
Every surface is gated; the gates differ in strength, and a few are weaker than they sound. Sites are public on the internet — the protection is that they're static, owned, and revertible.
| T1 | Deploy & manage sites | Per-user API key in X-API-Key (never Authorization: Bearer). Two registration paths mint one: email magic-link / 6-digit code, and owner Google sign-in. The key authenticates the owner of every upload, rollback and delete; each handler re-resolves the site by user_id, so a valid key for another account is a 404, not a leak. The key is stored in the users row as issued and matched by equality — there is no hash step. |
| T2 | Admin | Two independent paths: the ADMIN_API_KEY from the service environment file (constant-time compare, synthesises an in-memory admin that has no database row), and an is_admin flag on a real user. Never in code, config, or the repo. |
| T3 | Viewing sites | Public, with no exceptions. There is no password lock and no private mode: anything deployed is readable by anyone who has the address. Its state and collections are readable too, subject only to the Origin gate. |
| T4 | Per-site state & collections | Reads are Origin-checked — attribution-grade, not authentication-grade, and documented as such. Writes run through WRITE_AUTH_MODE: on — the mode live on simple-host.app since 2026-09-05 (the source default is log) — requires a visitor session plus X-SH-CSRF: 1 or an API key; log and off are operator opt-downs (log records the same decision and allows the write anyway; off skips the check). An admin-only per-site allow-anonymous-writes flag overrides even on. Visitor sessions exist only on custom domains; on the shared host writes are open, so a page there saves without sign-in and anyone can change that data. |
| T5 | Create with AI | Sign-in-gated and rate-limited, per user (burst 30, +1 per 10 s) and per IP (burst 20, +1 per 12 s), with a separate generous budget for status polling. The endpoint is not registered at all unless a provider key is configured. No model credential is ever emitted into a served page. |
| T6 | DB & secrets | Postgres credentials and every key live in one environment file outside the repo, read at boot by the non-root service user and never reachable from a served page. Postgres itself listens on loopback only. |
| T7 | Traffic analytics | Owner scoping is a WHERE clause in the SQL, not a filter applied to results, so one owner's traffic cannot appear in another's list. The instance-wide all=1 is admin-only and answers 403 to anyone else rather than silently narrowing the scope — a quietly narrowed answer is a lie about what was returned. |
Two limitations stated plainly, because both have surprised someone. The origin check on state reads is forgeable by anything that is not a browser — never put secrets in state. And every path-model site shares one origin, sites.simple-host.app, so one site's JavaScript can reach a sibling's state and storage: this platform does not offer site-to-site isolation in the browser, and no product copy should imply it.
Static hosting is commoditized. The difference here is that every site gets just enough backend — with no database to set up.
Per-site JSON state. One shared document per site (GET/PUT/PATCH /v1/sites/{site}/state, mirrored at /v1/u/{handle}/sites/{site}/state), with atomic ops — set, inc, append, remove, remove-where — applied under a row lock, and ETag / If-Match for cheap polling and optimistic concurrency. Counters, votes, saved app state, guestbooks.
Append-only collections. Growing lists (/collections/{name}) for signups, RSVPs and form submissions — cheap appends, paginated reads, and an owner-only CSV export.
Visitor sign-in on your own domain. A hosted auth.js helper gives a page Google or emailed-code sign-in, so visitors can write to 6.1 and 6.2. It works only on a site's own custom domain: on the shared host every site shares one origin, so no visitor sign-in is issued there — pages there save without one (anyone can change that data), and agents write with an API key anywhere.
Server-side visitor analytics. Per-site views and unique visitors, split into people, bots and monitoring, with an hourly and a daily trend — computed from the edge access log, so no tracking script and nothing to embed. IPs are salted-hashed and never stored raw. Section 07 is the whole pipeline.
All of it is backed by the same single Postgres — no per-site database, no separate service. The store is public to the site's own audience by design — sites and their data are public to anyone with the link. Note that llms.txt prescribes these exact request and response shapes to external models, so every site any LLM has generated from it is a live consumer of this contract.
The headline number is people, not requests. Getting those two apart was the entire point of this subsystem.
This box runs a directory service that probes every site's / every 30 seconds from 127.0.0.1 — 2,880 requests per site per day. Before traffic was classified that was roughly 97.8% of every recorded "view", which made the numbers worse than useless: a site with three real readers and a site with none looked identical. So nothing is counted now until it has been labelled.
nginx writes the ledger. Static pages are served straight off disk and the Go process never sees a page view, so the edge log is the only ground truth. A dedicated log_format shanalytics emits tab-separated ts · host · status · method · uri · remote_addr · user_agent into /var/log/simple-host/analytics.log. The directive is per vhost: a custom-domain server block added without that one line produces a site that silently records nothing.
A Go ingester tails it every 5 minutes. It resumes from a persisted (inode, byte offset), and that offset advances in the same transaction as the counter upserts — so a crash re-reads a batch instead of double-counting one. Each line is attributed to a site by host: content-host path, legacy label, or bound custom domain. Unattributable lines are skipped, never guessed. A run is capped at 200,000 lines and recovers from its own panics, so it can never take the server down.
Rotation is part of the design, not an afterthought. logrotate uses create plus delaycompress, and both are load-bearing. copytruncate would keep the inode and break offset tracking; and because the ingester drains the previous file as plain .log.1 and cannot read gzip, compression must be held back a full cycle. Change the logrotate stanza and you are changing the ingester's contract.
Three classes, and loopback always wins. Every request is labelled person (no automation signature), bot (crawlers, AI scrapers, SEO suites, security scanners, HTTP libraries — plus exploit paths like /wp-login.php regardless of what the User-Agent claims), or infra (loopback probes and hosted uptime checkers). A loopback address is infra whatever it calls itself. person is the default, deliberately: an unrecognised crawler inflates the human number rather than hiding real people, which is the safer direction to be wrong in.
Hourly buckets, split by class. site_view_hourly(site_id, hour, class, views) and site_visitor_hourly(site_id, hour, class, ip_hash) replaced the old date-grained tables. Hourly granularity is what makes "the last 24 hours" answerable at all, and the volume is trivial — a dozen sites × 24 hours × 3 classes is under 900 rows a day, so nothing is rolled up. Aggregates prune at 400 days.
Visitors are a salted hash, and the salt does not rotate. An IP is stored only as sha256(server-secret salt + ip) truncated to 16 bytes; the raw address is never written and the secret never leaves the box. The salt is stable on purpose. A per-day salt makes a returning visitor unlinkable across days, which makes "unique visitors over 30 days" impossible to compute — you are left summing daily uniques, which always overstates the audience. Stable salt is what makes a range total a real number instead of an inflated one.
The first month is kept, and honestly labelled. The pre-classifier *_daily tables are retained rather than dropped: they are the only surviving record of 2026-07-11 → 2026-08-08, whose raw logs have rotated away and cannot be rebuilt. The API reads them for any day before classified_from and reports them as a fourth class, unknown — plainly visible on the chart, never quietly folded into the human number.
GET /v1/sites/{name}/analytics returns totals, last_24h, daily and hourly, each as a class split, plus classified_from. The bulk GET /v1/analytics/sites exists because the dashboard must sort sites by traffic before it draws the first card, which one-request-per-site cannot do. A analytics-rebuild command replays the live log and rewrites every aggregate with the current classifier — so improving the classifier improves history, instead of leaving a permanent step in every chart.
What this cannot tell you, stated plainly. Identity is an IP hash, so an office behind one NAT reads as a single person and a phone moving from wifi to cellular reads as two. Only 200 and 304 responses to document requests count as a view — which means a scanner spraying 404s at /wp-login.php never reaches the bot column at all, because it never loaded anything; the bot number is "bots that actually read a page", which is the comparable figure to put next to people. analytics-rebuild replays only the live log, so anything already rotated out is beyond its reach. And there are no referrers, no per-path breakdown and no sessions — by choice, not by accident. Country (ordered by people, with bot and infrastructure counts alongside, from a separate /analytics/geo read) is the one geographic cut.
A chat that builds a site for you. The model is called server-side, so the provider key stays on the server and never reaches a served page.
There are two builders and they are not the same thing. The chat builder lives on the showcase / owner page: it calls POST /v1/generate, which answers with a job id, and the page polls GET /v1/generate/status?id=…. A full build takes a minute or more — far longer than a browser will hold an idle connection open — so the turn runs as a background job and nothing depends on one long-lived request. The dashboard builder on the home page is deliberately different: it asks you to build in your own AI and paste the files back, then publishes them. Both publish through the same authenticated files API.
The sign-in gate and the rate limits are enforced in the Go handler, not at the edge — nginx does no auth for this route. The model is given no tools: the request body carries messages and nothing else, no function or tool definitions exist, and the contract is a text sentinel the model emits before a complete self-contained HTML document. It has no file access and no shell. You preview in a sandboxed, opaque-origin iframe — srcdoc with allow-same-origin deliberately absent — and only then publish.
You can speak instead of typing — captions appear as you talk and the text stays editable before you send. Speech is transcribed on this box by a local service on loopback, so recordings are never handed to a third party; the browser's WebSocket authenticates with a short-lived HMAC ticket rather than your API key. You can also attach a screenshot or notes and have the page built from them.
Each job is bound to the user who started it: a poll for someone else's id reports "not found" rather than "forbidden", so ids cannot be enumerated. The store is in-process — 8-minute run ceiling, 3 builds in flight per user, 64 in total, and results dropped 10 minutes after they finish. One builder provider is configured, Grok, and there is no fallback: if it is down the build fails honestly instead of quietly switching to a metered third-party key. Worth being precise about where it runs — the request goes to an OpenAI-compatible proxy on this same box over loopback, which then relays to the vendor. The inference is off-box; the proxy and its credentials are not.
Where this actually runs: one box, one process, no orchestration.
One process behind nginx. A single Go binary under systemd, listening on loopback only — deliberately not a cluster. nginx terminates TLS, serves the content host by path straight off disk, proxies /v1/ and /internal/ to the app, and fronts every connected custom domain. There is no Caddy in this deployment; the on-demand-TLS endpoint in the code is a leftover from a design that was not adopted.
Certificates are certbot, and issuing one is a human step. Let's Encrypt certs per domain, renewed by a timer twice daily. Connecting a custom domain through the API only records the binding and creates the symlink, then hands back the DNS record to add — an operator still writes the vhost and issues the cert before that domain serves anything. Any copy promising instant custom-domain HTTPS is describing a system this is not.
Hardened & non-root. Runs as a dedicated simplehost user with NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, MemoryDenyWriteExecute, an empty capability bounding set, a syscall filter, and exactly one writable path — the sites directory. /healthz and /readyz (the latter pings the database) are real endpoints, but nothing is in front of them: on a single box they are observability, not a traffic gate.
The service is small; the box is not exclusively its own. The process is one static binary doing static serving and pass-through proxying, with no heavy compute in-process. It shares the host with Postgres, the local speech-to-text service, the model proxy, the directory prober and several unrelated services — which is exactly why the analytics classifier had to learn to tell that neighbourhood apart from real readers.
Releases are hand-swapped, and honestly so. Build, copy the binary in, restart; site data and versioned files are untouched. Timestamped simple-host.bak-* copies accumulate next to the live binary for rollback. There is no deploy script in the repo — the procedure lives in habit and prose, which is worth knowing before anyone describes it as automated.
Local state, and the background loops. Postgres on the same box, loopback-bound (users, sites, versions, per-site state, collections, analytics); the versioned site tree on local disk; no external object store. Inside the process: the analytics ingester every 5 minutes; the API-metrics flush every 20 seconds and its prune every 6 hours; the geo worker; domain re-verification every 2 minutes (releasing unproven bindings after 24 hours); the visitor-auth sweep hourly; and the preview-expiry sweep hourly when enabled. Each recovers from its own panics — none can take the server down.
| N1 | Not general compute | No user-code execution, by construction. No SSR, no backends, no PHP, no processes per site. |
| N2 | Static-only serving | HTML/CSS/JS/images/fonts served as bytes. Framework output is welcome; framework servers are not. |
| N3 | Not a secret store | Per-site state is readable by the site's audience and documented as such: sites and their data are public to anyone with the link. |
| N4 | Not browser-level isolation | Every path-model site shares one origin, so sites are not sandboxed from each other in the browser. This is a known, accepted trade of the path model — never imply otherwise in product copy. |
| N5 | Not analytics-as-a-product | No referrers, no per-path breakdown, no sessions, no cross-site identity — country is the one cut. Counting people honestly is the whole ambition. |
| N6 | Not bypassable by scale | Per-site size and state caps are enforced server-side; AI create and state writes are rate-limited per user and per IP. |