Helm chart for hosting sites at GEWIS. A release serves many sites from a two-level
domainGroups → domains model on one shared RWX volume: one FrankenPHP (Caddy with
embedded PHP) web server serves every domain from its own folder, and each domain group
gets its own in-browser code-server editor. Static files and PHP are both served.
domainGroups:
- name: board # editor + OIDC allow-list scope; folder name
domains:
- name: board.gewis.nl
- name: bestuur.gewis.nl
- name: cie
domains:
- name: cie.gewis.nl
writablePaths: [cache] # dirs PHP may write to (persisted, shared, not served)
On the shared volume this becomes site/<group>/<domain>/. Each domain serves its own
folder; /admin on every domain opens that group's editor (which sees only its
group's per-domain subfolders). Removing a domain archives its folder to
site/.archive/; removing a whole group leaves its folder orphaned (data kept, unserved).
The served tree is read-only at runtime. A domain may declare writablePaths — relative
dirs under its docroot that PHP can write to at runtime (caches, uploads). These are
mounted read-write from .writable/<group>/<domain>/<path> (so they persist on the RWX
volume and are shared across all caddy replicas) and are blocked from HTTP with a 404,
so they can neither be downloaded nor executed as PHP.
PersistentVolumeClaim— RWX, size fromstorage.size; holds thesite/<group>/<domain>/tree plus.state/(chart markers),.archive/, and.writable/(per-domain runtime-writable dirs, seewritablePaths).Deployment+Service(caddy) — FrankenPHP,caddy.replicas(default 3), read-only; each domain is routed to its own rootsite/<group>/<domain>and its PHP is confined to that folder viaopen_basedir. AnywritablePathsare mounted read-write over their in-docroot location; aseed-writableinit container pre-creates the mountpoints (the runtime cannot create a mountpoint inside the read-only tree).Deployment+Serviceper domain group — code-server editing only that group's folder (subPath mount); non-root, read-only rootfs, DNS-only egress. A rootseed-siteinit container seeds/archives/chowns the group's subtree before the editor starts.Job— installscodeServer.extensions(from Open VSX) plus, whencodeServer.phpantom.enabled, the PHPantom PHP language server (.vsix+ prebuilt binary) onto the shared volume, and re-runs when any of that changes; the only component allowed egress to fetch them. Shared by all editors.NetworkPolicy— denies code-server egress except DNS. Requires a CNI that enforces it.IngressRoute(Traefik) — every domain routes to Caddy;/adminon every domain routes to that group's code-server, gated by OIDC. If a group setsadminDomain, that editor is also served at the root of that hostname (OIDC-gated) — point its DNS at a WAF-bypassing path, since code-server's service worker breaks under the stripped/adminsubpath behind a WAF.Middleware—traefik-oidc-authper group plus sharedredirect+stripPrefix /admin.Secret oidc-secret— empty shell annotated for reflection fromshared-secrets/oidc-authby the emberstack reflector.
code-server is the only interactive surface, so it is locked down: non-root, read-only
root filesystem, all capabilities dropped, no service-account token, and DNS-only egress
(networkPolicy.enabled). The integrated terminal is enabled — safe because egress is
blocked, the rootfs is read-only, and nothing sensitive is mounted. Extensions are
pre-installed read-only and the marketplace is disabled, so the editor runs only what the
chart ships. User settings.json is seeded from the chart on each start; in-session edits
are allowed but reset to the chart defaults on every pod restart.
Group isolation has two layers: each group's editor is mount-isolated (it only mounts
its own site/<group> subPath), and each domain's PHP is confined to its own folder via
open_basedir. The latter is defense-in-depth, not a hard boundary — one shared FrankenPHP
process serves all groups, so do not treat it as isolation between mutually-untrusted tenants.
Because the web tier is multi-replica with per-pod /tmp, PHP sessions/uploads (default
/tmp) are not shared across replicas; sites relying on PHP sessions need sticky sessions
or shared session storage.
The served tree is mounted read-only on the web tier, so a compromised PHP site cannot
persist a webshell or tamper with content. writablePaths opens a deliberate, narrow
exception: only the listed dirs are writable, they live on the RWX volume (so writes are
shared across replicas and survive restarts), and they are blocked from HTTP (404) so a
file written there is never served or executed. Concurrent writers are possible at
caddy.replicas > 1; an app that cannot tolerate a torn read should write atomically
(temp file + rename).
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: gewis-webhost
namespace: flux-system
spec:
interval: 10m
url: https://gewis.github.io/webhost-helm-chart
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: myapp
namespace: flux-system
spec:
interval: 10m
releaseName: myapp
targetNamespace: webhost-myapp
install:
createNamespace: true
chart:
spec:
chart: static-webhost
version: 0.6.0
sourceRef:
kind: HelmRepository
name: gewis-webhost
namespace: flux-system
values:
storage:
size: 20Gi
domainGroups:
- name: myapp
domains:
- name: myapp.gewis.nl
writablePaths: [] # e.g. [cache] for PHP runtime writes
oidc:
groups:
- "GEWIS - Some Committee""CBC - Application Hosting Team (ADM)" always has editor access; per-group
oidc.groups are additionally allowed in for that group.
By convention, target namespaces are webhost-<release>; the chart no longer
enforces this so targetNamespace is yours to set.
helm repo add gewis-webhost https://gewis.github.io/webhost-helm-chart
helm install myapp gewis-webhost/static-webhost \
--create-namespace --namespace webhost-myapp \
--set 'domainGroups[0].name=myapp' \
--set 'domainGroups[0].domains[0].name=myapp.gewis.nl'| Key | Description | Default |
|---|---|---|
storage.size |
Size of the shared RWX volume | 10Gi |
domainGroups |
List of {name, domains[], oidc.groups[]}; one editor per group, each domain served from site/<group>/<domain> |
[{name: example, domains: [{name: example.gewis.nl}]}] |
domainGroups[].domains[].name |
Hostname, served from site/<group>/<domain> |
— |
domainGroups[].domains[].writablePaths |
Relative dirs under the docroot made writable + persisted (RWX, shared); blocked from HTTP (404) | [] |
domainGroups[].adminDomain |
Optional hostname that also serves the group's code-server at its root (OIDC-gated); point DNS at a WAF-bypassing path | "" |
domainGroups[].oidc.groups |
Extra OIDC groups allowed into that group's /admin (ADM always allowed) |
[] |
oidc.provider.url |
OIDC issuer URL | GEWISWG realm |
oidc.secretReflectsFrom |
Source for the reflected OIDC secret | shared-secrets/oidc-auth |
caddy.replicas |
FrankenPHP web-server replica count | 3 |
codeServer.image |
code-server editor image | codercom/code-server:4.125.0 |
caddy.image |
Web server image (FrankenPHP = Caddy + PHP) | dunglas/frankenphp:1-php8.5-alpine |
networkPolicy.enabled |
Restrict code-server egress to DNS only | true |
See values.yaml for the full schema.
The release-chart workflow publishes to gh-pages whenever a new chart
version is pushed to main. To cut a release:
- Edit templates/values as needed.
- Bump
version:inChart.yaml(semver). - Merge to
main. CI packages the chart and updates the Helm repo index.
If the version on main is already in index.yaml, the workflow skips
publishing — no clobbering of existing releases.
A Nix flake provides Helm:
nix develop
helm lint .
helm template demo . \
--set 'domainGroups[0].name=demo' \
--set 'domainGroups[0].domains[0].name=demo.gewis.nl'