Architecture Feature map Who renders what Traps Safety property Trust model The little backend Storage access Counting visitors Runtime Non-goals
Architecture brief · blast-radius map

Every place
a feature lives.

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.

simple-host.app · public playground · companion: /llms.txt · /openapi.yaml · /docs.html

Feature map

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.

Visitor analyticsanalyticsPer-site views and unique visitors from the edge log, split into people, bots and monitoring.
Gointernal/analytics/: ingest.go (tail, attribute, upsert) · classify.go (person/bot/infra) · rebuild.go · internal/db/analytics.go · internal/handler/analytics.go · cmd/analytics-rebuild/main.go · started at cmd/server/main.go
RoutesGET /v1/sites/{sitename}/analytics · owner
GET /v1/sites/{sitename}/analytics/geo · owner, people by country
GET /v1/sites/{sitename}/analytics/top · owner, top pages and referring domains
GET /v1/analytics/sites · owner-scoped; all=1 admin-only, 403 otherwise
UIindex.html → Sites tab, per-card slot #an-{name}: People/Bots/Infra cells + 24 h columns; also feeds the #site-sort ordering, and the same cards render inside the Admin tab
showcase.html → owner Analytics tab #owner-analytics-view: 7/14/30/90-day ranges, #an-totals + #an-body + sparklines. This is the surface that was missed.
Tableswrites site_view_hourly · site_visitor_hourly · analytics_ingest_state
reads those plus site_view_daily · site_visitor_daily (frozen history: never drop) and sites/users for attribution
Infranginx log_format shanalytics (per vhost) · /var/log/simple-host/analytics.log · /etc/logrotate.d/simple-host-analytics (create + delaycompress) · ANALYTICS_LOG env
Docsopenapi.yaml (TrafficCounts, TrafficSplit, both paths) · skills/website-deploy/references/operations.md · this page §07 · stale: docs/designs/analytics-design.md · absent from README.md, llms.txt, every SKILL.md
Also touchBoth consuming pages, every time: showcase.html reads only daily[].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.
Per-site JSON statestateOne shared JSON document per site with atomic ops and ETags. The widest blast radius on the platform.
Gointernal/handler/stateops.go (set/inc/append/remove/removeWhere under a row lock) · internal/handler/site.go (getSiteState, putSiteState, authorizeStateOrigin, resolveSiteID) · internal/handler/visitorsession.go (write gate) · internal/db/queries.go
RoutesGET · PUT · PATCH · OPTIONS /v1/sites/{sitename}/state
GET · PUT · PATCH · OPTIONS /v1/u/{handle}/sites/{sitename}/state (the v3 twin: both must change together)
UIevery site any LLM has generated from llms.txt, which prescribes this exact contract, plus the hosted auth.js sign-in helper those sites load before a save
No first-party dashboard renders state, which is precisely why a break here is invisible in-house.
Tablessites.state (jsonb) · sites.state_version · sites.updated_at · reads sites.allowed_origins, sites.allow_anonymous_writes
Docsopenapi.yaml (both path shapes) · llms.txt · skills/website-deploy/references/backend.md · website-deploy-builder/SKILL.md §2 · CLAUDE.md · docs/designs/state-storage.md (proposal, not shipped)
Also touchThe 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.
Collections and saved-data kindscollectionsLists plus Page info, Submissions (each visitor edits and withdraws their own), Personal records and Shared boards; paginated reads, CSV export, 30-day history and restore.
Gointernal/handler/collections.go · internal/db/collections.go · shares the Origin gate and write gate with state
RoutesGET · POST · OPTIONS /v1/sites/{sitename}/collections/{coll} + the /v1/u/{handle}/… twin
GET /v1/sites/{sitename}/collections · owner
GET /v1/sites/{sitename}/collections/{coll}/export.csv · owner
UIindex.html → Sites tab: #data-{name} collection list and #collview-{name}-{coll} row table, plus the CSV download button
plus the admin.html viewer that llms.txt requires every generated site to ship
Tablescollection_items + idx_collection_items (db/schema.sql)
Docsopenapi.yaml (both shapes + export.csv) · llms.txt · backend.md · website-deploy-builder/SKILL.md · operations.md
Also touchThe read shape {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.
Registration & API keysauthEmail magic-link or 6-digit code, exchanged for the per-user API key everything else depends on.
Gointernal/handler/user.go · internal/auth/middleware.go (X-API-Key, admin short-circuit) · internal/email (Resend) · internal/handler/handles.go (handle claimed at verify) · internal/db/queries.go
RoutesPOST /v1/auth · POST /v1/auth/verify · GET /v1/me · POST /v1/me/api-key/rotate · POST /v1/me/sign-out · GET/POST /v1/me/keys · DELETE /v1/me/keys/{id}
UIindex.html → auth view: #email-form-wrap, #code-form-wrap, #verifying-screen; /v1/me gates all of #main-view and the Admin tab's visibility
install.html describes the flow in prose
Tablesusers (handle, is_admin) · api_keys (id, key_hash, name, last4, last_used_at) · auth_tokens (expired codes purged)
Docsopenapi.yaml (AuthResponse) · skills/website-deploy/references/register.md · website-deploy/SKILL.md · README.md · CLAUDE.md
Also touch/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.
Owner OAuth sign-inoauth-ownerGoogle sign-in (more providers later) that lands on the same users row as the magic link.
Gointernal/handler/oauth.go (purpose="owner" fork, return_to classification) · internal/oauth/ (provider.go, google.go: PKCE) · internal/config
RoutesGET /v1/auth/oauth/providers · /v1/auth/oauth/{provider} · /v1/auth/oauth/{provider}/callback the same routes also serve visitor sign-in (an apex start for a site is sent on to the site-host start); a purpose forks the callback
UIindex.html → auth view #oauth-providers
the hosted auth.js helper on every site's own address
prescribed by llms.txt to every generated site
Tablesoauth_states (+ purpose CHECK) · oauth_identities · users · auth_tokens (owner handoff)
Docsopenapi.yaml · docs/history/SPEC.md · docs/history/UNIFY.md (account matching, credential boundary) · README.md · docs/designs/oauth2-authentication.md is superseded prior art
Also touch/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.
Visitor sign-in & sessionsvisitor-sessionsThe site-scoped identity that lets a stranger write to state or leave a comment.
Gointernal/handler/visitorsession.go (cookie __Host-sh_vsess, X-SH-CSRF, visitorWriteOK) · internal/handler/oauth.go (site-host start sets __Host-sh_vnonce; mints the one-time establish token bound to it) · internal/db/visitors.go
RoutesGET /v1/visitor/oauth/{provider} · GET /v1/visitor/establish (sets the session only in the browser holding the nonce) · GET …/me · POST …/visitor/auth · POST …/visitor/auth/verify · POST /v1/visitor/logout
UIthe hosted auth.js helper: asks GET …/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.
Tablesvisitor_sessions · visitor_establish_tokens · oauth_states · oauth_identities · users · reads sites for the write gate
Docsopenapi.yaml (VisitorAuthRequired) · docs/history/SPEC.md (whole document) · docs/history/UNIFY.md · llms.txt · backend.md · CLAUDE.md
Also touchThis feature's tables once shipped without a GRANT for the service role (fixed 2026-09-05); the symptom was a route that 500ed while everything around it looked healthy. Any migration that creates a table must grant it: verify grants after any migration, not just the SQL file.
Site create, list, delete & visibilitysitesThe core object. Almost every other feature hangs off a sites row.
Gointernal/handler/site.go · internal/handler/sitename.go (shape + reserved names) · internal/storage/disk.go · internal/db/queries.go
RoutesPOST · PUT · PATCH (rename) · DELETE /v1/sites/{sitename} · GET /v1/sites · PUT /v1/sites/{sitename}/visibility
UIindex.html → Sites tab #sites-container and → Admin tab #admin-sites-container
showcase.html → owner Sites tab #inventory, plus it seeds the Analytics tab's site list
showcase.html → public gallery #gallery (server-injected, not fetched)
admin.html → #users
Tablessites · cascades on delete to versions, collection_items, both analytics generations, legacy_hostnames, oauth_states, visitor_sessions
Docsopenapi.yaml · website-deploy/SKILL.md · operations.md (listing, delete) · README.md · CLAUDE.md
Also touchGET /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.
File uploaduploadTwo paths: inline JSON files, or a tar.gz/zip archive. Same guards, same versioning.
Gointernal/tarball/: extract.go (traversal, caps, secret skipping) · validate.go (script denylist) · sanitize.go (JSON path) · internal/handler/site.go · internal/storage/disk.go
RoutesPOST · PUT /v1/sites/{sitename}/files (JSON) · POST · PUT /v1/sites/{sitename} (archive)
UIshowcase.html → Sites tab: owners manage published sites. Their own apps and agents upload through the files API.
Tablessites · versions (disk_path, archive_sha256, status)
Docsopenapi.yaml (FilesBody) · website-deploy/SKILL.md · packaging-and-validation.md · frameworks.md · CLAUDE.md
Also touchPublishing clients use status codes as control flow: 404 on PUT means the site is missing, and 409 on POST means it already exists. The X-Content-Digest integrity header and the size caps are documented in packaging-and-validation.md, which agents follow step by step.
Versions & rollbackversionsEvery deploy is a new version; rollback re-points which one is live.
Gointernal/handler/site.go (listVersions, setActiveVersion) · internal/storage/disk.go (UpdateCurrent: copy, remove, rename) · internal/db/queries.go
RoutesGET /v1/sites/{sitename}/versions · PUT /v1/sites/{sitename}/active-version
UIindex.html → Sites tab #versions-{name}: expects a bare array
showcase.html → owner Sites tab [data-versions]: accepts an array or a {data:[…]} envelope
These two disagree today. If this route ever starts being notice-wrapped, showcase.html survives and index.html silently renders nothing.
Tablesversions · sites.active_version
Docsopenapi.yaml · operations.md ("there is no .../activate: this is the one") · README.md · CLAUDE.md §Versioning
Also touchBoth pages, and the envelope asymmetry above is a latent bug worth fixing while you are in here. Note also that versions.archive_sha256 is written but never read back, so there is no persisted integrity check to rely on.
Custom domainsdomainsBind a domain to a site, relay two DNS records (address + TXT ownership), verify the TXT and fetch it.
Gointernal/handler/domains.go · internal/db/domains.go · internal/storage/disk.go (BindDomain: a symlink in the domains farm) · publicsuffix (apex → A record, subdomain → CNAME)
RoutesPOST · GET · DELETE /v1/sites/{sitename}/domain · GET /internal/tls-ask (live on every compose instance)
UIindex.html → Sites tab only: connect/disconnect buttons; the POST response's dns.host and dns.value are read straight into the toast the user copies from
showcase.html shows a bound domain but cannot change it.
Tablessites.custom_domain · domain_status · domain_verified_at · domain_last_error · domain_token · domain_proof_exempt · domain_cert_requests
Docsskills/connect-domain/SKILL.md (the whole flow) · openapi.yaml · operations.md · website-deploy-builder/SKILL.md · docs/designs/per-user-subdomains-and-custom-domains.md
Also touchnginx: a new domain gets its vhost and certificate from the root issuer in deploy/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.
Write gate & allowed originswrite-gateDecides whether a write to state or collections is allowed. Enforcement is on: a write needs a visitor session on the site's own address, or an API key.
Gointernal/handler/visitorsession.go (visitorWriteOK: on | log | off) · internal/handler/site.go (authorizeStateOrigin, setAllowedOrigins, setAllowAnonymousWrites) · internal/config/config.go (WRITE_AUTH_MODE)
RoutesPUT /v1/sites/{sitename}/allowed-origins · owner
UINone. No screen exposes either switch, which is exactly why the deployed mode has to be checked, not assumed.
Tablessites.allowed_origins · sites.allow_anonymous_writes
Docsdocs/history/SPEC.md (the write-auth spec) · docs/history/UNIFY.md (credential boundary) · openapi.yaml · backend.md · CLAUDE.md · README.md
Also touchon 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.
Handles & public showcaseshowcaseEvery user gets a person page, <handle>.simple-host.app, listing their public sites; each site lives at <site>.<handle>.simple-host.app.
Gointernal/handler/showcase.go (server-renders showcase.html, injects the data blob) · internal/handler/handles.go (reserved names) · internal/handler/ui.go (the catch-all that routes /{handle})
RoutesGET /internal/showcase/{handle} (nginx-only) · GET / catch-all (serves the owner app for a handle-shaped path)
UIshowcase.html → public gallery #public-view / #gallery: rendered from a server-injected blob, not a fetch
showcase.html → owner console #owner-view with its Sites and Analytics tabs
index.html links to it via #showcase-banner
Tablesusers.handle · sites (visibility=unlisted is filtered out of the gallery)
Docsdocs/history/2026-07-14-simple-host-showcase-and-404.md · register.md (the handle is the URL segment) · openapi.yaml (visibility)
Also touchThe injected blob is substituted by string replacement in Go: renaming a placeholder in the HTML breaks rendering with no compile error. And this one file is both a public page and the owner console, so a change aimed at one audience lands on the other.
Skills hub & installersskillsThe agent-facing product: three skills, an MCP server, and the installers that fetch them.
Gointernal/handler/skillshub.go · internal/handler/ui.go (zips, install scripts, PluginVersion) · the embedded simple-host-website/ tree
RoutesGET /v1/skills · /v1/skills/{name} · /v1/skills/{name}/references/{file} · /.well-known/skills/… · /skills.zip · /skills/version · /skills/<name>.zip · /skills/<name>/SKILL.md · /plugin.zip · /install.sh · /install.ps1
UIinstall.html: Get started: the connector per chat app and the one-line skill install · docs.html → #install-skills for manual installs
showcase.html → #skill-install for owners
index.html fetches /skills/version to set its skill-version header
TablesNone.
Docsthe three SKILL.md files and five references/ files are themselves the artefact · README.md · simple-host-website/README.md · docs/designs/simplify-and-share.md
Also touchThe plugin version lives in plugin.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.
Stale-skill notice injectionnoticeCross-cutting: rewrites JSON bodies to warn an agent its skill is out of date.
Gointernal/handler/notice_middleware.go: buffers the response and injects _notice when X-Skill-Version names an older skill (none at all: a plain API call, no notice); wraps roughly two dozen routes
RoutesMost of /v1/sites/* and /v1/auth/*. Deliberately not state, collections, static serving or skill downloads.
UIInvisible but decisive: index.html and showcase.html both unwrap {data:[…]} on /v1/sites because of this middleware. index.html additionally fetches /skills/version so its own calls are never wrapped.
TablesNone.
DocsCLAUDE.md §Skill staleness notice · website-deploy/SKILL.md · README.md
Also touchAdding a route to the wrapped set changes its response shape for every browser caller that does not send the header. That is the same failure mode as the analytics bug, arriving from the middleware instead of the handler.
Branded 404 & legacy hostsnotfoundA miss on the content host comes back as a branded page pointing at the owner's showcase.
Gointernal/handler/showcase.go (notFound, reads X-Original-URI) · internal/handler/legacyhost.go (global middleware: old per-site hostname → path URL)
RoutesGET /internal/notfound (nginx error_page) · plus a 301 applied to every request
UInotfound.html: server-rendered by string substitution of the message and back-link placeholders
Tablesreads sites and users to decide the back-link. Not legacy_hostnames: that table has no live reader.
Docsdocs/history/2026-07-14-simple-host-showcase-and-404.md
Also touchThe nginx 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.
Health & readinesshealthTwo probes. One is static, one touches the database.
Gointernal/handler/health.go
RoutesGET /healthz (static ok) · GET /readyz (2 s DB ping, 503 on failure)
UINone, and nothing in nginx consults them: on one box they are observability, not a gate.
Tablesping only
Docsopenapi.yaml · README.md
ProductInternal: no user-facing feature entry.
Also touchIf a load balancer or a monitor is ever pointed at these, they become traffic-affecting and the classifier should be checked: a hosted uptime checker lands in the infra class, which is where you want it.
Dashboard & static pagesdashboardEverything served out of the embedded static directory.
Gointernal/handler/ui.go (catch-all file server + the dashboard CSP) · internal/handler/showcase.go (ownerAppOrStatic)
RoutesGET / catch-all (serves docs.html, privacy.html and static assets) · GET /openapi.json · GET /openapi.yaml (audience-filtered public reference)
UIindex.html (auth view · Sites tab · Admin tab) · showcase.html (public gallery · owner Sites tab · owner Analytics tab) · admin.html · install.html · docs.html · privacy.html · notfound.html · architecture.html (this page)
Tablesthe catch-all looks up users for handle-shaped paths
DocsCLAUDE.md · docs/designs/install-page-redesign.md · docs/designs/simplify-and-share.md
Also touchThese files are embedded into the binary: editing one changes nothing until a rebuild and restart. Every "I fixed the page" claim should be verified against the running server, not the file on disk.

Who renders what

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.

E1GET /v1/sites/{name}/analytics multiindex.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.
E2GET/PATCH …/state multiauth.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.
E3GET /v1/sites multiindex.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.
E4GET /v1/auth/oauth/providers multiindex.html auth view · auth.js · prescribed by llms.txt. Adding or renaming a provider changes all of them.
E5GET /v1/me multiindex.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.
E6GET …/versions multiindex.html expects a bare array; showcase.html accepts either shape. Already inconsistent: wrapping this route would break exactly one of them, silently.
E7POST /v1/auth · /v1/auth/verify multiindex.html auth view.
E8PUT …/active-version · DELETE /v1/sites/{name} multiBoth pages' site lists call these.
E9POST …/collections/{coll} multiEvery generated site; all of them branch on visitor_auth_required.
E10GET /v1/analytics/sites oneindex.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".
E13PUT …/visibility oneshowcase.html owner Sites tab only.
E14…/domain · …/collections · export.csv oneindex.html Sites tab only.
E16GET /openapi.yaml multidocs.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.

Traps that have already bitten

Not hypotheticals. Each of these has cost someone real time here.

X1

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.

X2

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.

X3

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.

X4

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.

X5

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.

X6

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.

X8

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 core safety property

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.

4.1

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.

4.2

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.

4.3

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.

4.4

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.

4.5

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.

Fig 01 · Upload, validation & serve pipeline Platform Accepted Rejected
People & agents
Coding agent
holds the user's API key
uploads .tar.gz / .zip · or JSON files
Viewer's browser
GET https://<site>.<handle>.simple-host.app/
public on the internet
1 · upload  ·  GET site path
Platform · Go writes · nginx serves
Upload API · Go
X-API-Key → authenticated owner
2 · validate in memory ↓
Host match
<site>.<handle> → that site's current/
[a-z0-9-] captures · no traversal ↓
Validation gate
100 MB/file · 500 MB total · 50k entries
traversal blocked twice · symlinks dropped
Static file serve
bytes + content type only
no SSR · no exec · no autoindex
.sh .php .py … refused outright · .env and other secrets silently skipped · nothing written to disk
3 · write vN · copy into current/  ·  read-only bytes from current/
Storage · versioned disk
Versioned site tree
v1 … vN · one per upload, owned + timestamped
current/ is a copied directory · remove + rename
Postgres
users · sites · versions
per-site state & collections

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.

Trust model

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.

T1Deploy & manage sitesPer-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.
T2AdminTwo 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.
T3Viewing sitesPublic, 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.
T4Per-site state & collectionsReads 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.
T5Site storageEach 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.
T6DB & secretsPostgres 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.
T7Traffic analyticsOwner 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.

The little backend

Static hosting is commoditized. The difference here is that every site gets just enough backend: with no database to set up.

6.1

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.

6.2

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.

6.3

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.

6.4

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.

6.5

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.

Storage access

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.

PresetReadAddEditDelete
Public content publicMenus, price lists, event schedules, FAQsAnyoneOwnerOwnerOwner
Inbox inboxContact forms, quote requests, feedback, newsletter sign-upsOwnerAnyoneOwnerOwner
Wall wallGuestbooks, comments, reviews, a public RSVP listAnyoneSigned inOwnerOwn
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 showsAnyoneAnyone, spam-checkedOwnerOwner
Each person's records recordsShop orders, bookings, applications, support ticketsOwnSigned inOwnerOwner
Personal data personalProfiles, saved settings, drafts, notesOwnSigned inOwnOwn
Shared board boardTeam task lists, sign-up sheets, a club roster, a shared shopping listSigned inSigned inSigned inOwner
Private privateInventory, internal notes, configuration, a CRM. The default.OwnerOwnerOwnerOwner

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:

  • Edit and delete are never anyone. An anonymous visitor could overwrite or wipe the whole resource. Anonymous add stays allowed, for contact forms.
  • Add is never own. Nothing belongs to anyone before it exists; use signed in, and the server records who added each entry.
  • If any action is own, add cannot be anyone. An anonymous entry has no author.
  • Edit and delete are never wider than read. Nobody changes what they cannot see.

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.

Counting who actually visited

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.

7.1

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.

7.2

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.

7.3

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.

7.4

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.

7.5

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.

7.6

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.

7.7

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.

Fig 02 · Analytics pipeline Platform Counted Stripped as infra
Edge · nginx
Real site requests
content host · legacy host · custom domain
served off disk: Go never sees a view
Loopback probe
nginx-directory · 127.0.0.1 · every 30s
2,880 hits / site / day
1 · log_format shanalytics · TSV → /var/log/simple-host/analytics.log · logrotate create + delaycompress
Ingester · in-process, every 5 min
Tail & attribute
resume at (inode, offset)
host → site · 200/304 documents only
Classifier
loopback → infra, first and always
UA + exploit paths → bot · else person
2 · one transaction: counter upserts and the offset advance commit together
Postgres · hourly aggregates
site_view_hourly
(site, hour, class) → views
pruned at 400 days
site_visitor_hourly
(site, hour, class) → ip_hash
sha256(stable secret + ip)[:16]
site_view_daily · site_visitor_daily: frozen, never dropped, read back as class “unknown”
3 · totals · last_24h · daily · hourly · classified_from
API & the two surfaces that render it
GET /v1/sites/{name}/analytics
index.html → Sites tab card + 24h columns
showcase.html → owner Analytics tab
GET /v1/analytics/sites
owner-scoped in SQL · all=1 admin-only
index.html → Sites tab ordering

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.

Runtime & deployment

Where this actually runs: one box, one process, no orchestration.

R1

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.

R2

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.

R3

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.

R4

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.

R5

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.

R6

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.

Fig 03 · Runtime: one box, one process Platform Traffic
Edge · nginx · certbot
nginx
TLS · content host by path, served off disk
proxies /v1/ + /internal/ to 127.0.0.1
Let's Encrypt
one cert per domain, issued by the domain-certs timer
renewed by certbot.timer
HTTPS · and the shanalytics access log
One box · systemd · non-root
simple-host · Go
REST API · dashboard · showcase · embedded skills
/healthz · /readyz · loopback only
Sandbox & loops
ProtectSystem=strict · one writable path
analytics 5 min · api-metrics 20 s
reads / writes
Local state
Postgres
users · sites · versions · state
collections · analytics
Versioned disk
/srv/simple-host/sites
v1 … vN · current/ · handle + domain farms

What it deliberately is not

N1Not general computeNo user-code execution, by construction. No SSR, no backends, no PHP, no processes per site.
N2Static-only servingHTML/CSS/JS/images/fonts served as bytes. Framework output is welcome; framework servers are not.
N3Not a secret storePer-site state is readable by the site's audience and documented as such: sites and their data are public to anyone with the link.
N4Isolation is per originEach 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.
N5Not analytics-as-a-productNo 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.
N6Not bypassable by scalePer-site size and state caps are enforced server-side; Site deploys and state writes are rate-limited per user and per IP.