Simple Host Enterprise / Architecture

How Simple Host Enterprise is built

Simple Host Enterprise is one Go service that runs in your own Kubernetes cluster. It serves static pages plus the data those pages save. Every site lives at its own address under its owner's name, and is its own browser origin, so no page can read another site's, not even a sibling site of the same person. Everyone signs in through your identity provider, unless an admin has opened a site to the whole network. Uploaded files are stored and served as bytes; they never execute on the server.

One serviceYour clusterYour PostgresYour bucketYour identity provider

Try it in your organization → Set up

01.

Identity and addresses

Every request says who is asking, and every site sits on its own browser origin.

  1. 1.1
    One sign-in, yours. People sign in through your identity provider: anything that speaks OIDC. The provider must vouch for the email address (email_verified), or sign-in is refused. There are no local accounts and no admin key. Admins are real signed-in people, named by an email list or a claim from your provider.
  2. 1.2
    An address per site. Every site is served at the root of its own address, under its owner's name: <site>.<person>.<your domain>, for example tracker.<person>.<your domain>. Each site is its own browser origin, so no page can read another site's pages, storage or cookies, or call its saved data, including a sibling site of the same person. <person>.<your domain>/ is that person's index of their sites. Teams are named team-<name> and work the same way. Only that person or team can publish there.
  3. 1.3
    Until a person's certificate is ready. Each person needs a certificate for their sites (R11). Until it is issued, their sites are served at <person>.<your domain>/<site>/, with the same sign-in and access checks, and share that origin for the time being. Once it is ready, those addresses redirect to the site's own. Nobody is sent to an address without a certificate.
  4. 1.4
    Management stays on the base address. The dashboard, the API and the MCP endpoint are served on <your domain>. No hosted page is ever served there.
  5. 1.5
    Moving between addresses. The browser signs in once, at the base address. Opening a site's address hands the session over with a one-time code that expires after 60 seconds and is spent by its first use. Each host then holds its own __Host- cookie (Secure, HttpOnly), bound to that host and refused on any other.
  6. 1.6
    Changes need the right Origin. A change made with a browser cookie must carry the Origin of the address it was sent to. A page on one address cannot make changes on another.
  7. 1.7
    AI apps sign in as the person. ChatGPT, Claude and other AI apps connect to /mcp with OAuth: the person signs in through your identity provider and allows the app once. The app's tokens carry that person's rights and no more, and work only on /mcp; a direct API call with one is refused. An access token lasts an hour, and the connection lasts 30 days from the sign-in that made it, however often it refreshes; then the person signs in again (both configurable, at most 24 hours and 90 days). OAUTH_REDIRECT_HOSTS limits which apps can finish connecting. The apps must be able to reach your instance. Each installation serves its own plugin, with its address already filled in.
  8. 1.8
    API keys are for automation. CI and scripts use an API key. Only a signed-in person can create one. Keys are stored as a hash, expire (90 days by default, 365 at most) and can be revoked. Each key has a scope, set when it is created: publish (the default: deploy, update, roll back, saved data and files), full (everything the person can do except administration) or offboard (admins only: the offboarding call and nothing else). No other key reaches the admin API.
  9. 1.9
    Session lifetimes. A browser sign-in lasts 8 hours, or 30 minutes unused, by default. Set both to match your identity provider's policy, up to 24 hours and 8 hours; a value out of range stops the service from starting.
  10. 1.10
    Shared rate limits. Sign-in, the session hand-off, key creation and the AI-app connector's token and registration endpoints are counted in Postgres, so every replica shares one budget. Other limits are counted per pod.
FIG 01 · Identity and one origin per site Simple HostPeople & yoursSigned-in request
People, agents & your identity provider Employee's browser signs in once, at the base address one __Host- cookie per host · HttpOnly Your identity provider anything that speaks OIDC decides who can sign in AI app or automation AI app: OAuth, as the person CI: a scoped, expiring API key OIDC tracker.person-a.<your domain> Person A's tracker served at the root: / one site, one origin its own cookies + storage saved data + files API served on this host sign-in on every view, unless an admin opens a site to the network new sites: owner only notes.person-a.<your domain> Person A's notes served at the root: / apart from the tracker, though the owner is one saved data + files API served on this host sign-in on every view, unless an admin opens a site to the network new sites: owner only roadmap.person-b.<your domain> Person B's roadmap shared with specific people named viewers only: people or teams everyone else gets 404 person-b.<your domain>/ is Person B's index <your domain> · base address Management + API dashboard · keys · deploy MCP endpoint at /mcp no hosted pages here cookie-signed changes must carry this address as their Origin AI apps: OAuth, /mcp only CI: a scoped API key one-time hand-off, then visits OAuth · API key no site's pages can read another site's · the browser withholds every cross-origin response

Scroll the figure sideways →

One sign-in through your provider, then a separate origin per site, each under its owner's name. Management lives on the base address, where no hosted page runs.
02.

File safety

Uploaded files are data. JavaScript can run in a viewer's browser; nothing uploaded runs on the server.

  1. 2.1
    No server-side execution path. Published files are stored and served as bytes with a content type. The server never evaluates them. Each published version is stored once in the bucket and never changed.
  2. 2.2
    Four archive limits. By default a publish request is capped at 100 MiB. Each file may be up to 500 MiB once extracted, the whole archive up to 500 MiB, and an archive may hold at most 50,000 entries (the request cap and the entry count are settings). Each entry's size is checked before it is read. Files sent inline through MCP are capped at 8 MiB.
  3. 2.3
    Broad acceptance, small denylist. Any regular file is accepted, including ZIP and DMG downloads. Refused extensions: .sh .bash .zsh .fish .bat .cmd .ps1 .py .pyc .rb .pl .go .php .exe.
  4. 2.4
    Two confinement layers. Every path is reduced to a canonical relative path, and anything unsafe (../, an absolute path, a backslash) is refused. Separately, object keys in the bucket are built only from validated IDs, and each pod's local copy of the sites it serves is confined with Go's os.Root, so nothing can reach outside it.
  5. 2.5
    Files a page uploads are checked by their bytes. The files API accepts images, audio, video, PDF, plain text, JSON, CSV and archives, judged from the content, not the name. Nothing is ever stored or served as HTML. Defaults: 25 MiB per file, 500 MiB and 5,000 files per site. Anyone who can open the site uploads and lists files; deleting one takes the person who uploaded it, the owner or a teammate.
  6. 2.6
    Quotas per owner. Each person and each team has its own limits, checked in the same transaction that records a publish or an upload: 1,000 sites, 10 GiB stored, and 5 versions kept per site by default. Older versions are retired.
  7. 2.7
    Optional malware scan. Point it at a ClamAV daemon and every file of a publish and every uploaded file is scanned before anything is stored. An infected upload is refused, and so is every upload while the scanner is unreachable. Off unless you set it.
FIG 02 · Upload, validation and serving Simple HostPeopleAcceptedRefused
People & agents Coding agent publishes a .tar.gz or .zip · 100 MiB max Viewer's browser GET /tracker/ · JavaScript may run here Validation & serving Upload API request body capped at 100 MiB Validation gate canonical relative paths only 500 MiB per file · 500 MiB total 50,000 entries at most small extension denylist owner quota · optional ClamAV scan Refused unsafe path · over a limit denied extension over quota · infected Path check owner and site segments validated Static file serve live version named by Postgres sends bytes + content type browser JavaScript only server execution: never Storage Your bucket one object per version · never changed Pod-local cache bounded · under os.Root 1 · upload 2 · validate 3 · store 4 · fetch GET site path read-only bytes

Scroll the figure sideways →

Files go in through four limits, a canonical path check and the owner's quota, are stored in the bucket once, and come back out as bytes from a local cache. JavaScript runs in the viewer's browser, never on the server.
03.

Who can see what

Every site has one of five access levels. A new site opens only for its owner, who decides from there.

Only meThe default for a new site. Only the owner can open it, or, for a team's site, the team's members.
Specific people or teamsThe owner names the people or teams who can open it, and everyone else gets a 404, the same answer as a site that does not exist. People can be named by company email before they have ever signed in; the grant takes effect at their first sign-in.
Anyone in the companyAnyone signed in who has the link. The link can be passed on, so this is not private.
ListedAnyone in the company, and the site appears in the company showcase and in search, credited to its owner.
Anyone on the networkNo sign-in. The owner asks for it with a reason, and the site keeps its level until an admin approves; you can require two different admins. Whoever asked can never approve their own request. Visitors who are not signed in can read its pages, files and saved data, and change nothing. The owner can lower the level at any time, and an admin can revoke it. An admin can also restrict any site to its owner with a reason the owner sees; until it is lifted, the owner cannot open it up, rename it or move it.
TeamsA team is a namespace, like a person, named team-<name>: its sites are at <site>.team-<name>.<your domain>. Every member can publish and manage its sites, which is how people work on a site together.
Saved dataAnyone who can open a site can read and write its saved data, as themselves. Writes go into the audit log under the person who made them. The last 20 versions are kept and the owner or team can restore one. A whole-site download link lasts minutes and stops working as soon as the sign-in, key or AI app that asked for it is revoked.
LeaversYour identity provider is asked about a person only when they sign in, so offboarding is two steps: disable them there, and disable them here. Disabling here ends every session and revokes every API key and connected AI app at once. An admin does it on the admin page, or HR automation calls the offboarding endpoint with an offboard key. Without the second step, a browser session lasts until its lifetime runs out (8 hours, or 30 minutes idle, by default), a connected AI app until its connection expires (30 days by default), and an API key until it expires. An admin then moves the leaver's sites to a team or a person, or deletes them, and can export or erase everything held about them; an erased identity cannot sign back in until an admin allows it.
04.

Records

Changes and visits are written down, where an owner or admin can read them back.

Audit logChanges to sites and access: publishing, updating, deleting and rolling back a site, renames, hand-overs and restores, admin restrictions, access-level changes and network requests, approvals and declines, named viewers, team changes, saved-data writes, and file uploads and deletions. Also sign-in, refused sign-ins and sign-out, API keys created and revoked, AI apps connected and revoked, and admin actions. A site change, a sign-in or a key change is written in the same database transaction as its audit row, and the row's time comes from the database clock. A few writes, such as refusals and sign-out, are recorded separately, retried, and logged loudly if they still fail.
Tamper evidenceEvery new audit row is hash-chained to the one before it, inside Postgres. A command walks the chain and reports the first break. Keep the head hash it prints somewhere the database owner cannot edit. The access log is not chained.
To your SIEMEvery audit event is also written to standard output as one JSON line marked "type":"audit", for the cluster's log shipper to forward. If output stalls, lines are dropped and counted in a metric; the database stays the record of truth.
Access logOne row for every hosted page served, the owner's and team members' own visits included.
Who reads themAn owner reads the audit log for their own sites and their teams' sites. From the access log they see views per day and how many different people visited, not who. An admin sees who, reads everything and can export it. A setting can instead show owners each visit, or hide the access log from everyone but admins.
Append-onlyThe database role the service runs as can add rows to both logs but cannot change or delete them. The service refuses to start if its role could change the audit log.
RetentionBy default, 400 days for the audit log and 90 days for the access log. A daily job drops expired partitions and trims the audit chain with them.
Request logOne JSON line per request on standard output, with an ID returned as X-Request-Id.
Client addressesTaken from X-Forwarded-For only when the request comes through a trusted proxy (TRUSTED_PROXY_CIDRS, private ranges by default). The same address is used for rate limits and in every log.
05.

Runtime

The service, a small certificate reconciler and one scheduled job in your cluster. Postgres, the bucket and sign-in are yours.

R1DeploymentTwo replicas. A rolling update starts a new pod before it stops an old one, a PodDisruptionBudget keeps one serving through node drains, and the replicas spread across nodes when there are several. Pods are stateless: sites live in the bucket, and Postgres decides which version is live.
R2Schema stepAn init container, migrate, runs the same image before the server starts. It takes a lock, applies pending migrations as the database's owning role, each in one transaction with its record, and exits. The server refuses a schema it does not know, unless every newer migration is marked backward-compatible, so an image can be rolled back without restoring the database.
R3Least-privilege roleThe server connects as simplehost_app, which can read and write application data but cannot change the schema or truncate tables.
R4HardeningThe namespace enforces the restricted Pod Security profile. Every container runs as a non-root user with a read-only root filesystem, all capabilities dropped and the default seccomp profile. No Kubernetes API token is mounted in the service's pods.
R5Pod storageEach pod keeps a bounded cache of the versions it serves in an emptyDir, emptied on start. /tmp is a second scratch emptyDir. A pod holds nothing that is not in the bucket or the database.
R6Ingress and metricsThe base address and a wildcard beneath it, on one TLS certificate, plus one Ingress per person for their sites (R11), all to one ClusterIP Service. One DNS wildcard record covers every depth. How it is exposed is yours to decide; AI apps can connect only if they can reach it. Prometheus metrics are on a separate port, 9090, which the Service does not expose; scrape the pod directly.
R7Scheduled jobprune runs daily as the owning role. It drops expired log partitions and their audit-chain rows, and deletes ended sessions and old hand-off codes. It touches only Postgres.
R8Your PostgresOver TLS with sslmode=verify-full; the service refuses to start otherwise. Point-in-time recovery is required: Postgres holds which version of each site is live, the saved data and the accounts, and nothing in the package backs it up. Every page request looks up the live version there, so the sites need it running.
R9Your bucketAn S3-compatible bucket over HTTPS is the site store: every version and uploaded file, written once. Bucket versioning with a lifecycle rule is required and is the backup for those files. Every object carries a server-side-encryption header, with an optional client-side envelope on top; reencrypt rewrites every object under a new envelope key so the old one can be retired. The service refuses a plain-HTTP endpoint.
R10ReleasesRelease images are signed with cosign (keyless, from the release workflow) and carry an SBOM and a provenance attestation. There is no latest tag: the manifests pin an image digest, and the install check refuses the placeholder.
R11CertificatesA wildcard certificate per person, *.<person>.<your domain>, issued automatically by cert-manager. A small reconciler keeps one Ingress per person with sites; it runs under its own ServiceAccount, may manage Ingresses and read Certificates only, and never reads Secrets. Your company's internal CA is the recommended issuer: no limits and no cost. Public CAs publish every hostname, and so everyone's name, in Certificate Transparency logs, and Let's Encrypt caps certificates per week. Automatic certificates are tested with an in-cluster ingress controller; AWS ALB and GKE's built-in ingress need an in-cluster controller for the site addresses, or certificates provided by hand.
FIG 03 · Runtime in your cluster Simple HostYoursRead / write path
Namespace simple-host · pod security: restricted Ingress → Service :80 <your domain>, *.<your domain> · plus *.<person>.<your domain> per person R6 Deployment · 2 replicas · rolling update · PDB R1 init: migrate same image, runs first applies pending migrations as the owning role, then exits R2 simple-host × 2 serves every host on :8080 DB role simplehost_app no schema changes R3 Local cache emptyDir · emptied on start versions being served /tmp: emptyDir R5 R4 every container: non-root user read-only root filesystem all capabilities dropped seccomp · no API token CronJob prune · daily owning role · Postgres only drops expired log partitions R7 Metrics :9090 Prometheus /metrics not in the Service or Ingress R6 Deployment owner-hosts · certificate reconciler own ServiceAccount · Ingresses + reading Certificates · no Secrets one Ingress per person → cert-manager issues *.<person>.<your domain> R11 Yours Your identity provider any OIDC provider sign-in for people and AI apps Postgres R8 TLS · sslmode=verify-full point-in-time recovery which version is live, saved data, accounts, logs S3-compatible bucket R9 HTTPS · the site store versions + files, written once versioning on: the file backup SSE header · optional envelope owning role

Scroll the figure sideways →

Tags match the rows above. The server reaches Postgres as simplehost_app; the schema step and the prune job use the owning role, and the reconciler connects as simplehost_app. The pods keep only a cache: sites live in the bucket.
06.

Boundaries

  1. N1
    Not a server-side compute platform. Pages can run JavaScript in the browser. Nothing uploaded runs on the server.
  2. N2
    Not a secrets store. Anyone who can open a page can read what it saves. Keep secrets out of pages and saved data.
  3. N3
    Not a content scanner. Files are checked for size, path, extension and type, and for malware if you turn on the ClamAV scan. Nothing looks for confidential data. The source is yours to extend if you want that.
  4. N4
    Not independently tested. Simple Host is a side project built by one person. Its security testing so far is the author's own scripted tests, run against a local cluster; no independent penetration test has been done yet. Review the code and run your own security review before you depend on it.