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 every site address <site>.<handle>.simple-host.app (one origin per site), every person page <handle>.simple-host.app (showcase or selected home, with a live feed, bio, pins and order), the old content host sites.simple-host.app (old links redirect), 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.
To run it on a server of your own: Run your own → Set up.
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/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.__Host-sh_vnonce; mints the one-time establish token bound to it) · internal/db/visitors.goGET …/me, and SH.requireSignIn() offers Google or an emailed 6-digit code before the page saves. Works on the site's own address: <site>.<handle>.simple-host.app, or the site's custom domain / claimed <name>.simple-host.app when it has one; the session is bound to that host and covers that one site. Every page save needs it; agents write with an API key. 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.X-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 fromdeploy/domain-certs/ (template vhost.conf.template), and that vhost must carry the shanalytics 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.<handle>.simple-host.app, listing their public sites; each site lives at <site>.<handle>.simple-host.app.#public-view / #gallery: rendered from a server-injected blob, not a fetch#owner-view with its Sites and Analytics tabs#showcase-banner#install-skills for manual installs#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 names an older skill (none at all: a plain API call, no notice); 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.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. |
| 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". |
| E13 | PUT …/visibility one | showcase.html owner Sites tab only. |
| E14 | …/domain · …/collections · export.csv one | index.html Sites tab only. |
| 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.
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. 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, off disk. A site host <site>.<handle>.simple-host.app serves only that site's current/ directory, with the site and handle taken from host labels limited to [a-z0-9-]: traversal is not expressible in them; custom-domain files are served by nginx straight from the same directory. 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. Keys are stored only as SHA-256 hashes in api_keys and looked up by hash: each sign-in adds a key, and rotating replaces them all with one new key. |
| 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 two exceptions: the owner may open a whole site only to named viewers, people named by email who sign in on the site (everyone else gets a sign-in page or “This site is private”, checked against the list on every request), or put one shared passcode on a whole site, and every address of it then shows a plain “This site is protected” page until the visitor enters it (an unlock cookie for that one host; the passcode is sealed with a server key so the owner can read it back). It is not a login: anyone given the passcode can pass it on, and Simple Host can still read the site. There is no per-page lock and no private mode: anything else deployed is readable by anyone who has the address. Its state and public collections are readable too, subject only to the Origin gate; private Submissions are readable only by their owner (and each visitor's own entries by that visitor); Personal records only by their person and the site's own pages. |
| 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). Visitor sessions are issued on the site's own address (<site>.<handle>.simple-host.app, or its custom domain), so every page save there needs a signed-in visitor. |
| T5 | Site storage | Each SQLite table, key-value namespace, and file bucket carries its own answer for read, add, edit, and delete, checked by the server on every page request, which never carries SQL. Edit and delete are never open to anonymous visitors. See Storage access. |
| 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 isolation is per origin: every site at <site>.<handle>.simple-host.app is its own origin, so one site's JavaScript cannot reach another's storage or sign-in. Hosted Simple Hack gives each team site its own origin at <team>.<event>.simple-hack.app, and a custom event website has its own event origin. Older self-hosted event instances may keep the path model and share an origin.
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.
Collections and saved-data kinds. Growing lists (/collections/{name}) for signups, RSVPs and form submissions, and declared kinds under /data/{name}: Page info, Submissions (each visitor edits and withdraws their own), Personal records and Shared boards: paginated reads, an owner-only CSV export, and 30-day history and restore.
Visitor sign-in on the site's own address. 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 on the site's address <site>.<handle>.simple-host.app, and on a site's custom domain or claimed <name>.simple-host.app when it has one. Every save from a page needs a signed-in visitor; agents write with an API key. A collection can be made private: only signed-in visitors can submit, and only the owner reads it.
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.
Site storage: tables, key-value, and files. Each site can keep SQLite databases, key-value namespaces (one JSON value per key), and file buckets for uploads. They are kept apart from the site's own files and survive redeploys. Who may use each one is set by its access, described next.
6.1 to 6.4 are backed by the same single Postgres, with no separate service. In 6.5, key-value data lives in that Postgres too, and each SQLite database and file bucket is kept on disk next to the site, outside its published versions. The state and collections in 6.1 and 6.2 are public to the site's own audience by design. 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.
Every SQLite table, key-value namespace, and file bucket answers four questions: who may read, add, edit, and delete. Each answer is one of five values: nobody (only the owner's tools: their key, the connector, and the dashboard), owner, own (the person who added that entry, and the owner), signed in (anyone signed in on the site, and the owner), or anyone. Add also takes a sixth, checked: anyone who passes the spam check below. The server decides which action a request is: a write to a key or path that does not exist yet is an add, and a write to one that exists is an edit. The owner's tools always have full control; the answers govern requests from the site's pages.
An agent picks one of eight presets when it creates the resource. SQLite takes a preset per table, with a database default, so one database can hold public products and private orders.
| Preset | Read | Add | Edit | Delete |
|---|---|---|---|---|
Public content publicMenus, price lists, event schedules, FAQs | Anyone | Owner | Owner | Owner |
Inbox inboxContact forms, quote requests, feedback, newsletter sign-ups | Owner | Anyone | Owner | Owner |
Wall wallGuestbooks, comments, reviews, a public RSVP list | Anyone | Signed in | Owner | Own |
Comments commentsA guestbook, comments, reviews, or questions posted without signing in. Each post first passes a Cloudflare Turnstile spam check; optionally the owner approves each one before it shows | Anyone | Anyone, spam-checked | Owner | Owner |
Each person's records recordsShop orders, bookings, applications, support tickets | Own | Signed in | Owner | Owner |
Personal data personalProfiles, saved settings, drafts, notes | Own | Signed in | Own | Own |
Shared board boardTeam task lists, sign-up sheets, a club roster, a shared shopping list | Signed in | Signed in | Signed in | Owner |
Private privateInventory, internal notes, configuration, a CRM. The default. | Owner | Owner | Owner | Owner |
Any one action can be changed on top of a preset. The server checks every matrix when it is saved and refuses an unsafe one with 400 invalid_access, so it is never stored:
Enforcement. The server builds every statement. Visitors never send SQL: a page calls a fixed route with an action and values, and the server checks that action's answer, quotes real table and column names, binds every value, and sets id, visitor_id, created_at, and updated_at itself. An own edit or delete carries visitor_id = the signed-in caller inside the statement, and a change that would touch more than its one row, or write to another table through a trigger, is refused. When the site's owner signs in on the site's own address with their account email, the site's pages act with owner rights on its storage data only, never on settings, deploys, domains, or keys, and never on an answer of nobody. Spam-checked posts. A site's own hostnames are too many to list on one Turnstile widget, so the check runs in a small frame served from simple-host.app (/v1/turnstile/frame). auth.js embeds it when a save answers spam_check_required, exchanges messages with it only through that frame's window and origin, and sends the save again with the one-time token. The token's cdata binds it to the page's origin and the resource; the server checks it with Cloudflare's siteverify (success, action, the app's hostname, that binding, under five minutes old, used once) and refuses on any failure or timeout. The visitor's IP address is never sent. Such a post is small (4,000 bytes, two links) and rate limited per address and per site. Resources saved before presets keep their earlier rules and show as legacy; the older read, write, and write_mode fields are still accepted and translated into the four answers when saved. Simple Hack uses the same presets except comments: a team's owner rights end at its submission deadline, so nobody would be left to remove or approve anonymous posts, and people sign in to post there (wall).
Limits. 10 MB per website for SQLite and key-value together, and a separate 10 MB per website for files. One file upload and one query result are each at most 1 MB.
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 · referrer_host · bytes_sent (query string stripped; the bytes feed the admin page's network figures) 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 sessions and no full referrer URLs: by choice, not by accident: people's views are also counted per page and per referring domain (nginx logs only the referrer's host name; /analytics/top, top 20 of each). Country (ordered by people, with bot and infrastructure counts alongside, from a separate /analytics/geo read) is the one geographic cut.
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. That is this deployment. The compose install this repo also ships runs Caddy with on-demand TLS, which asks /internal/tls-ask before issuing each certificate: that endpoint is current and load-bearing there, not a leftover.
Certificates are certbot, issued by a root timer, not by the app. Let's Encrypt certs per domain, renewed by a timer twice daily. Connecting a custom domain through the API records the binding and creates the symlink, then hands back the two DNS records to add (address and TXT ownership). Once both are seen the app drops a request file (DOMAIN_CERT_DIR/requests/<domain>); the root issuer (deploy/domain-certs/) runs certbot HTTP-01, writes the vhost from its template, reloads nginx and marks it ready, or writes the reason it failed, which the domain status shows. On a compose instance Caddy issues it on demand. HTTPS arrives minutes after DNS, not instantly.
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 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 | Isolation is per origin | Each site at <site>.<handle>.simple-host.app is its own origin, so sites are sandboxed from each other in the browser. On event instances, which keep the path model, every site shares one origin. Never imply more isolation than this in product copy. |
| N5 | Not analytics-as-a-product | No sessions, no full referrer URLs, no cross-site identity: country, top pages and referring domains are the only cuts. Counting people honestly is the whole ambition. |
| N6 | Not bypassable by scale | Per-site size and state caps are enforced server-side; Site deploys and state writes are rate-limited per user and per IP. |