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.
Identity and addresses
Every request says who is asking, and every site sits on its own browser origin.
- 1.1One 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. - 1.2An address per site. Every site is served at the root of its own address, under its owner's name:
<site>.<person>.<your domain>, for exampletracker.<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 namedteam-<name>and work the same way. Only that person or team can publish there. - 1.3Until 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. - 1.4Management 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. - 1.5Moving 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. - 1.6Changes 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.
- 1.7AI apps sign in as the person. ChatGPT, Claude and other AI apps connect to
/mcpwith 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_HOSTSlimits 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. - 1.8API 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) oroffboard(admins only: the offboarding call and nothing else). No other key reaches the admin API. - 1.9Session 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.
- 1.10Shared 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.
Scroll the figure sideways →
File safety
Uploaded files are data. JavaScript can run in a viewer's browser; nothing uploaded runs on the server.
- 2.1No 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.2Four 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.
- 2.3Broad 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. - 2.4Two 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'sos.Root, so nothing can reach outside it. - 2.5Files 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.
- 2.6Quotas 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.
- 2.7Optional 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.
Scroll the figure sideways →
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 me | The default for a new site. Only the owner can open it, or, for a team's site, the team's members. |
|---|---|
| Specific people or teams | The 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 company | Anyone signed in who has the link. The link can be passed on, so this is not private. |
| Listed | Anyone in the company, and the site appears in the company showcase and in search, credited to its owner. |
| Anyone on the network | No 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. |
| Teams | A 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 data | Anyone 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. |
| Leavers | Your 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. |
Records
Changes and visits are written down, where an owner or admin can read them back.
| Audit log | Changes 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 evidence | Every 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 SIEM | Every 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 log | One row for every hosted page served, the owner's and team members' own visits included. |
| Who reads them | An 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-only | The 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. |
| Retention | By 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 log | One JSON line per request on standard output, with an ID returned as X-Request-Id. |
| Client addresses | Taken 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. |
Runtime
The service, a small certificate reconciler and one scheduled job in your cluster. Postgres, the bucket and sign-in are yours.
| R1 | Deployment | Two 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. |
|---|---|---|
| R2 | Schema step | An 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. |
| R3 | Least-privilege role | The server connects as simplehost_app, which can read and write application data but cannot change the schema or truncate tables. |
| R4 | Hardening | The 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. |
| R5 | Pod storage | Each 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. |
| R6 | Ingress and metrics | The 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. |
| R7 | Scheduled job | prune 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. |
| R8 | Your Postgres | Over 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. |
| R9 | Your bucket | An 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. |
| R10 | Releases | Release 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. |
| R11 | Certificates | A 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. |
Scroll the figure sideways →
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.Boundaries
- N1Not a server-side compute platform. Pages can run JavaScript in the browser. Nothing uploaded runs on the server.
- N2Not a secrets store. Anyone who can open a page can read what it saves. Keep secrets out of pages and saved data.
- N3Not 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.
- N4Not 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.