Installing Gentian OS
Follow these steps in order to get a cluster running. Each one says what to do and how to tell it worked.
Re-running ./install.sh is always safe. It reads the cluster to decide what is
already done, so a second run continues rather than restarting.
The whole install, once you are configured (steps 1–3):
./install.sh # asks what the cluster is, installs everything, writes the recovery kit, then waits
It then waits for the things only you can do: move the kit somewhere safe, sign
in at https://platform.<your-domain>/, and supply the runtime credentials the
console asks for. It sees the sign-in, finishes handover itself, and prints
Install Complete.
For flags, troubleshooting and the non-default installs — internal domains, mirrors, uninstalling — see docs/reference/install.md.
The steps, in the order you do them:
| Step | You are done when | |
|---|---|---|
| — | What you need, and on a machine that installed before | pre-flight passes |
| 1–3 | Clone the deployments repository, write install.env, have the credentials ready | the two stage files are pushed and you hold the token and the master password |
| 4 | ./install.sh | it prints Almost There: 1 step left |
| 5 | Handover: keep the kit, sign in | it prints Install Complete |
| 6 | Check | the cluster is ready for a tenant |
| 7 | Multi-tenancy: create the first tenant. Either mode: open the tenant's App Store | the App Store lists apps |
| 8 | Only for a cluster that runs your own services from a private repository | your service answers at its public address |
What the installer does
install.sh works through a directory of small steps, each named
<phase>-<number>-<what-it-does>. The letter is the phase; the number orders
steps within it. Five phases, in this order:
| Phase | Steps | What it builds | |
|---|---|---|---|
| A | control-plane | A-01 … A-09 | Namespaces, cert-manager, External Secrets, Crossplane, Envoy Gateway, Argo CD, metrics-server, pre-pulled images, cluster issuers |
| B | secrets | B-01 … B-10 | The kernel bootstrap Applications, the transit seal and OpenBao, Crossplane providers and definitions, the seeded credentials, the deployment signing keys |
| C | platform | C-01 … C-05 | The Cluster claim, the ApplicationSets, the wildcard certificate, the credential catalog, the repository hand-off |
| D | applications | D-01 … D-05 | The operator and the director, the kernel hostnames resolving, the kernel realm and the platform desktop, OpenBao's OIDC login, the kernel Gateway |
| E | handover | E-01 … E-05 | The recovery kit, then revoking the installer's own credential; on a single-tenancy cluster, the user tenant after that; last, the catalog and every tenant declared in git synced (E-05), which before the platform admin's first sign-in stays outstanding |
./install.sh --explain prints every step with what it provides and what it
mutates, straight from the step files, so it cannot drift from what runs.
Before any of that there is a step 0: the cluster's definition in
gentian-deployments. If it exists the installer reads it; if it does not, the
installer asks for every setting — each with its default already in the answer
— writes the files, commits them signed and pushes them. Nothing on the cluster
is touched until that is done. Step 4 below is what that looks like.
Each step reports what it found before it changes anything:
[A-02] cert-manager
provides: cert-manager controller and its CRDs in the edge namespace
check: not satisfied → applying
✓ 34s
check: satisfied → skip means that step's work is already present, which is
why re-running continues rather than restarting. You can run one phase with
--phase secrets, or one step with --only A-02.
From phase C onward much of the work is Argo CD and Crossplane converging on their own, so the installer finishing is not the same as the cluster being ready — step 6 covers how to tell the difference.
Nor is it the same as the cluster being yours: phase E ends by revoking the credential the installer used, and it will not do that until an administrator has signed in. Step 5 is that handover, and the cluster holds tenants back until it is done.
What you need before you start
-
A Kubernetes cluster you are an admin on, version 1.33 or newer. The installer does not create one, and pre-flight refuses an older one: the edge it installs, Envoy Gateway 1.9, is tested on Kubernetes 1.33 to 1.36.
kubectl versionshows the server's. It runs 100+ pods, so a laptop-sized node pool will be tight.-
It needs cluster DNS, RBAC and a default StorageClass. On MicroK8s those are the add-ons
dns,rbacandhostpath-storage(or a StorageClass of your own, named at thestorageClassquestion in step 4). The installer raises MicroK8s's limit of 110 pods per node to 220 itself, which restarts MicroK8s once and asks forsudo. -
An older MicroK8s is upgraded one minor version at a time, never across several — Kubernetes supports no other way. From 1.31, for example, wait for the node to be
Readyafter each of:sudo snap refresh microk8s --channel=1.32/stable && microk8s status --wait-readysudo snap refresh microk8s --channel=1.33/stable && microk8s status --wait-readykubectl get nodes # VERSION reads v1.33.x -
Use a
kubectlno more than one minor version away from the cluster's. An older one still works for most things and prints a warning about version skew on every call. -
Secrets encrypted at rest. Kubernetes stores every Secret in etcd, and without an encryption provider at the API server that is plain base64 for anyone who holds an etcd snapshot. On your own control plane, give the API server an
EncryptionConfigurationbefore installing; on a managed one, ask the provider and record the answer when step 0 asks forcontrolPlane.secretsAtRest. Pre-flight warns until the claim saysproviderorcluster, or the API server's flags show it. Switched on after the fact, runscripts/tools/rewrite-secrets.sh --applyonce: a Secret written before stays plain until it is written again.
-
-
These tools on your
PATH:kubectl helm jq yq openssl curl git gpg crossplane python3 age age-keygen. Pre-flight checks and names any that are missing, and refuses to start without them.bao(the OpenBao CLI) is the one tool the installer fetches itself when it is missing: to~/.local/bin, for Linux and macOS on x86_64 and arm64, and only after the archive matches the release's own checksum list. On any other machine installbaofirst.gitandgpgare needed before the cluster is touched: step 0 commits this cluster's definition togentian-deployments, and every commit to that repository is signed — Argo CD refuses unsigned ones. The keys are generated for you, in~/.gentian/gnupg, never in your own keyring.ageships both binaries —sudo apt install age,brew install age,apk add age. It encrypts the recovery kit, and it generates this cluster's backup key — the age key pair every scheduled export encrypts to. Nothing else can make one; without it the install would finish and every nightly backup would fail withno age recipients configured, for as long as nobody looked.
-
Optional:
qrencode—sudo apt install qrencode,brew install qrencode,apk add qrencode. The recovery kit prints the backup key as text either way; withqrencodeit also prints it as a QR code, which is what you keep on paper. Pre-flight warns if it is missing, since the kit is written near the end of a long install. -
A domain, for example
platform.example.com. It does not have to be publicly resolvable. On a public one, these names have to reach the cluster, and their certificates are issued over DNS (step 3):Name What answers there <domain>,www.<domain>the page that sends each person to their tenant platform.<domain>the platform admin's desktop admin.platform.<domain>the platform admin's console id.<domain>the identity provider, for every tenant *.<tenant>.<domain>multi-tenancy, one tenant: desktop.,admin.,store.and each appdesktop.<domain>,admin.<domain>,store.<domain>,<app>.<domain>single-tenancy: the one tenant for users, which has no label of its own (step 7) With a DNS provider named in step 4 (Cloudflare by default) and its token supplied, the cluster publishes these records itself and the install waits for them. Without one you maintain them:
<domain>,*.<domain>,*.platform.<domain>and, on a multi-tenancy cluster, one*.<tenant>.<domain>per tenant, pointing at the cluster. -
A way for traffic to reach the cluster, which is the
networkModequestion of step 4. The installer sets up neither:-
tunnel(the default) is a Cloudflare Tunnel. Create the tunnel in your Cloudflare account and runcloudflaredfor it in the cluster before the install: the platform does neither, and the tunnel's routes point at a Service name only a pod in the cluster can resolve. The installer needs the tunnel's id and has one source for it, the Secretcloudflaredruns with, in the namespacedefault:kubectl create secret generic cf-tunnel -n default \--from-literal=token=<the tunnel's token># or, for a tunnel run from a credentials file:kubectl create secret generic tunnel-credentials -n default \--from-file=<tunnel-id>.json=<the tunnel's credentials file>The pre-flight stops the install when it finds neither, with these two commands. Step
B-08stores<tunnel-id>.cfargotunnel.comwith the Cloudflare DNS credential, so a tunnel cluster needs Cloudflare as its DNS provider (acme-dns01,cloudflare). The operator then writes the tunnel's routes itself and publishes every hostname as a record pointing at the tunnel, through external-dns, which is installed on a tunnel cluster too. A tunnel carries HTTP and HTTPS only, so mail goes out through a relay. -
static-ipneeds a fixed address that DNS points at and something that answers aLoadBalancerService with it: MetalLB on your own machines (on MicroK8s the add-onmetallb, given that address), or the cloud's load balancer. The platform's own mail needs this mode, and a few things at your provider before you install: an address to send from, its reverse DNS, outbound port 25. mail.md §9c has the list. Skip it if the cluster relays its mail.
-
-
A token with write access to
gentian-deployments. The installer pushes this cluster's definition with it, and the director on the cluster pushes every later change — tenants, app installs — with it too. The installer asks for it.
On a machine that installed before
The installer renders the cluster from the files on this machine and reads back whatever an earlier install left. Five things carry over, and each of them quietly gives you the earlier cluster instead of the one you mean:
-
This checkout. Bring it to the commit you want to install, and reinstall the CLI from it:
git pull --ff-only && git log --oneline -1make install-plugin -
A pinned image in
install.env. A lineGENTIAN_OS_IMAGE_TAG=…left from an earlier run is used as it stands: the cluster runs that older build under the current manifests. The installer warns only when the value is a tag that moves (develop), not when it is an old build. Delete the line (step 2). -
A list of default profiles in
install.env. A lineGENTIAN_DEFAULT_PROFILES=…(orGENTIAN_STORE_CATALOGUE_URL=…) is used in place of the default too, an empty value included. An entry that is a local file — the form earlier installs used — is now refused and stops step 0, and so does one whose catalog lists no digest for it; one whose catalog cannot be reached is a warning, and the cluster then comes without the profile. Delete the line; unset, step 0 places the Operations Console from the catalogGENTIAN_STORE_CATALOGUE_URLnames, only if the file hashes to the digest that catalog's index lists (reference/install.md §4). -
The cluster's definition in the deployments repository. If
clusters/<cluster-id>/exists there, step 0 asks nothing and rewrites nothing: the domain, the tenancy mode, the catalog and the store are the ones inkernel/claims/cluster.yaml, and every tenant directory undertenants/comes back with the cluster. Read the claim before you start. To change a setting, edit it in your deployments checkout and let./install.shcommit it (never commit there yourself once the signing keys exist). In particular, a claim written by an earlier installer may name a store and a catalog that never served anything; the installer warns about both, and the App Store stays empty until they read as a new claim's do:catalogue:storeUrl: https://<store address> # GENTIAN_STORE_URLsources:- name: gentianurl: https://gentian-org.github.io/gentian-apps/develop # without /develop when installing a release -
The signing keys and the wildcard certificate in
~/.gentian. Keep them: the keys are the ones the repository's history is signed with, and the certificate saves one of the five a week Let's Encrypt issues for a set of names. The installer looks the break-glass key up by the id the repository records for this cluster (signing/keys.env). On a machine whose keyring does not hold that key it refuses to install, before writing anything: run./install.sh --recover <kit>there first, or install from the machine that has the key. Only when the key is lost for good,./install.sh --rotate-break-glass-keyrecords a new one (reference/install.md §9).
1. Clone the deployments repository
This cluster's configuration lives here, and the installer reads and writes it on your machine. Clone it to the default location:
git clone <your-gentian-deployments-url> ~/.gentian/gentian-deployments
To keep it somewhere else, set GENTIAN_DEPLOYMENTS_PATH in step 2.
An empty repository is enough: the install writes what it needs into it
(step 4), including profiles/_base.yaml and profiles/<stage>.yaml, the two
values files Argo CD layers under the platform's own chart. It writes each
only when it is missing, as a commented template that sets nothing, and
commits it with the cluster's definition. Files that are already there are
left as they are.
Make every commit of your own now. Once a cluster is installed from the repository, Argo CD syncs it only while its newest commit is signed by one of that cluster's keys (below). The repository holds the cluster's state only: a catalog of your own profiles is never kept in it (step 8).
2. Write install.env
cp install.env.template install.env
Edit it. These are the values that matter for a first install:
| Variable | Set it to |
|---|---|
GENTIAN_DEPLOYMENTS_REPO / _BRANCH | Your deployments repository |
GENTIAN_DEPLOYMENTS_CLUSTER_ID | This cluster's ID. It names the directory under clusters/ and, with the stage, the Cluster claim — get it right before step 4, which pushes the tree it names |
GENTIAN_DEPLOYMENTS_STAGE | dev, staging or prod — it names profiles/<stage>.yaml, which the install writes if the repository has none |
GENTIAN_OS_BRANCH | Leave it unset to follow the branch this checkout is on (git branch --show-current). Set it to follow a release tag (v1.2.3): a checked-out tag has no branch to read, and the install stops and asks |
GENTIAN_DEPLOYMENTS_AUTH | basic, the default: a user name and a token, which a private repository needs. none for a public one |
Leave GENTIAN_OS_IMAGE_TAG and GENTIAN_DEFAULT_PROFILES unset, and remove
either line if an earlier install left it
(above). The installer then runs the
build of exactly the commit you are installing from, and pre-flight says so
if that build has not been published yet.
Leave the rest as it is. Everything below those lines in the template is optional and commented out, with its default.
Nothing about the cluster itself is set here. Its domain, network mode,
certificate issuer, mail mode, tenancy mode, secret mode, storage class and
models live on the Cluster claim, which the install asks for in step 4.
install.env says how to run the install; the claim says what the cluster is.
A cluster setting written into install.env (TENANCY_MODE, SECRET_MODE,
NETWORK_MODE, MAIL_SERVICE_MODE and the others the template lists at its
end) overrides the claim on every later run, and the installer warns about it.
3. Have the credentials ready
The install asks for these, validates each against the system it belongs to, and stops before touching the cluster if any fail.
| Required | What it is | |
|---|---|---|
deployments-repository | yes | Write access to the repository from step 1 |
master-password | yes | At least 16 characters |
gentian-os-repository, gentian-ui-repository | no | Only when the matching GENTIAN_*_AUTH in install.env is not none |
| DNS provider credential | under acme-dns01 | For the provider named in step 4. Cloudflare, the default: an API token with Zone → Zone → Read and Zone → DNS → Edit on the domain's zone — needed by the default issuer, see below |
| Cloudflare Tunnel API token | under networkMode: tunnel | Account → Cloudflare One Connector: cloudflared → Edit. Not the tunnel's own token (that one is in the Secret cloudflared runs with). It is checked and stored in OpenBao at gentian-os/kernel/edge/cf-tunnel. As installed, the operator writes the tunnel's routes with the DNS token, not with this one — so on a tunnel cluster give the DNS token this permission too (one token with all three permissions, entered at both questions, is the simple case). To have the operator use this token instead, see below. Not asked on a static-ip cluster |
To give the operator the tunnel token as a credential of its own, with the DNS
token left at its two zone permissions, add this to
clusters/<cluster-id>/kernel/values.yaml in the deployments repository once
step 0 has written that file, and run ./install.sh again:
cloudflare:
tunnelAPITokenSecretRef:
name: cloudflare-tunnel-api-token-gentian-system
openbaoPath: gentian-os/kernel/edge/cf-tunnel
Type them when asked. Each reaches OpenBao once it exists, and a later run
recovers it from there instead of asking again — so a resumed install does not
re-ask. Nothing is written to this machine except a short-lived cache that step
B-08-seed-secrets deletes.
Everything else is supplied after the cluster is up, not now — you set it when you first sign in, in step 5.
The Cloudflare token is only optional if this cluster's issuer does not need
it. Under the default acme-dns01, DNS-01 issues every kernel certificate,
not just the wildcard, so an absent or rejected token leaves the cluster with no
working TLS. Choose acme-http01 (public DNS, port 80 reachable, no wildcards)
or self-signed (internal domains) at the issuer question in step 4 if you do
not want to supply one. On a tunnel cluster the installer asks for the tunnel
token as well. One Cloudflare token may hold both permissions; it is then
entered twice, once at each question — see
routing.md §3a for what to grant it.
If a value is rejected, the installer names where it came from and asks for a replacement rather than aborting.
4. Install
./install.sh
One command, start to finish. Before the first phase it runs step 0: it
finds no definition for this cluster in gentian-deployments and asks for
one. Every question shows its default; Enter takes it.
| Question | Default | Choose otherwise when |
|---|---|---|
| Kernel domain | — | Always asked; there is no default |
networkMode | tunnel | DNS points straight at a node — static-ip, which then asks for nodeIp |
certificates.issuerMode | acme-dns01 | The domain is not publicly resolvable (self-signed), or port 80 is reachable but you have no DNS API token (acme-http01) |
certificates.acmeEnv | production | Never needed for rebuilds — a purge keeps the issued wildcard in ~/.gentian/certs and the next install reuses it (only --purge --cluster-infra deletes it). staging breaks the kernel sign-in, which does not trust its chain |
certificates.dnsProvider | cloudflare | The zone is hosted elsewhere |
mail.serviceMode | external | You want the platform's own mail, in-cluster Postfix and Dovecot (system; refused with networkMode: tunnel, so it needs static-ip). Not asked on a tunnel cluster, which is always external. A cluster with its own mail needs things arranged at your provider before the install, and a second load-balancer address: mail.md §9c lists them. With a relay, none of it applies |
mail.egressHost | unset | system mode: the name outbound mail leaves from, for example mail-egress.<domain>. Asked after the mail mode. It needs a floating IP of your own on one node, with its reverse DNS set to this name; the pre-flight stops the install when that is not so. Blank sends from the provider's shared address, which most receivers treat as spam (mail.md §9c) |
mail.host | unset | external mode: the relay's hostname, for example smtp.example.com. Its user name and password are a credential, supplied in step 5 — not asked here |
platform | detected from the nodes | Detection is wrong for your provider |
storageClass | the cluster default | The cluster has more than one StorageClass |
tenancyMode | multi | Who the cluster is for. multi: the platform tenant plus any number of user tenants, each at desktop.<tenant>.<kernel-domain>; the install creates none. single: the platform tenant plus exactly one user tenant, named user, on the cluster's own addresses (desktop.<kernel-domain>); the install creates it after the handover. The platform admin signs in at platform.<kernel-domain> either way. See step 7. |
| Second factor for the platform admin | true | The platform admin should activate the account without enrolling one |
secretMode | derived | You want independent random secrets rather than ones reproducible from the master password: with random, the credentials the platform generates, the kernel's and each app's, are drawn at random and kept only in OpenBao; two kernel credentials stay derived. An app's own secrets are carried in the tenant's backups, encrypted, so that restored data stays readable. Choose it at the first install; a later change leaves every existing credential as it is. Tenant backups work the same in both modes, and OpenBao on Raft storage is snapshotted every day; if its storage is lost on a random cluster, every service and app is locked out of its store until the vault is restored from a snapshot (security/design.md §6, recovery.md §4) |
backup.escrowIdentity | true | The backup key should live in the recovery kit only, never in OpenBao |
openbao.storage | raft on a new claim | Not asked. Where the vault and the transit seal that unseals it keep their data; Raft is snapshotted every day (recovery.md §4). A claim without it is file, which takes no snapshot; moving such a cluster is recovery.md §5, never an edit of this line |
llm.enabled | false | This cluster serves models; then llm.gpuAcceleration is asked too |
llm.console.enabled | false | Not asked; set it in the claim. Platform administrators should have the model gateway's own console at llm.<kernel-domain>. Off, there is no such address and the gateway is reached from inside the cluster only. A cluster installed before this setting existed has it off after its next run, and the install says so |
llm.providers, llm.instances | none | Not asked; set them in the claim. The models the gateway offers: external OpenAI-compatible providers' models, and the models a cluster with GPUs serves itself. A commit to the claim changes the gateway's model list, with no run of the install. A provider's token is a credential, entered in the administration console; it appears there with the run after the claim first names the provider. An instance is offered by the gateway and not started by the platform (reference/install.md, llms.md) |
Two common installs answer like this; everything not listed takes its default:
| Question | A test cluster: tunnel, a mail relay, several tenants | A production cluster: its own address and mail, one tenant |
|---|---|---|
| Kernel domain | a domain in your Cloudflare zone | the organization's domain |
networkMode | tunnel | static-ip, then nodeIp |
certificates.issuerMode, dnsProvider | acme-dns01, cloudflare | acme-dns01, and the provider that hosts the zone |
mail.serviceMode | not asked: external. Then mail.host, the relay | system, then mail.egressHost |
tenancyMode | multi | single |
| Credentials asked after step 0 | the deployments token, the master password, the DNS token, the tunnel token | the deployments token, the master password, the DNS provider's credential |
| Supplied in step 5 | the relay's user name and password | nothing for mail |
| After the handover | you create tenants (step 7) | the install creates the tenant user (step 5) |
With the answers it writes clusters/<cluster-id>/kernel into your deployments
checkout:
| File | What it is |
|---|---|
claims/cluster.yaml | Everything that describes this cluster — every setting above, set or commented with the default in effect |
claims/deployments-repository.yaml | Where the director pushes. Without it every write answers 503: no tenant can be created and nobody can be invited |
claims/gentian-os-repository.yaml, claims/gentian-ui-repository.yaml | The platform's own repositories, as Argo CD may read them |
values.yaml | This cluster's Helm overlay |
signing/director.asc, signing/break-glass.asc, signing/keys.env | The two public keys Argo CD will accept commits from, and their ids |
Beside kernel it writes tenants/platform (the platform tenant),
catalogue/ (the default profile) and, on a single-tenancy cluster,
tenants/user. At the top of the repository it writes profiles/_base.yaml
and profiles/<stage>.yaml when they are missing.
Then it commits and pushes them, signed with the break-glass key. Two keys
are generated for this cluster in ~/.gentian/gnupg the first time: the
director key, which the director on the cluster signs with, and the
break-glass key, which signs what a person writes directly — and before the
cluster exists, that is the only way its first claim can get there at all.
git log --show-signature in the deployments repository afterwards says which
commits a person made and which the director made.
Before any of this, step 0 brings your deployments checkout up to date with
its remote — a fast-forward when that is all it takes. If the checkout has
local commits or uncommitted changes that origin does not, it stops and shows
the two ways to reconcile (pull --rebase to keep them, reset --hard to
discard them); which one is your call. If the push itself fails — no
credential yet — it stops too and prints the command to run by hand: Argo CD
syncs from the repository and not from your checkout, so nothing it syncs —
the claims, later the tenants — reaches the cluster until the push lands.
There is no claims/infra-data.yaml. The shared Postgres, MariaDB, Redis and
SeaweedFS are composed with the cluster into the system tier, and the installer
refuses to commit a claim whose kind this checkout does not define — so a
leftover from an older checkout stops step 0 rather than reaching the cluster.
Step 0 done, the install collects the credentials from step 3 and works through phases A to E, reporting each step before acting. Re-running it is safe: every step checks whether its work is already done and skips if so, and step 0 asks nothing on a re-run — the settings are read back from the claim. Expect it to take a while in B, where OpenBao is deployed and initialized, and in C and D, where Argo CD pulls and syncs the platform's own applications.
It ends in one of two ways:
Gentian OS — Almost There: 1 step left— everything is installed and handover remains. The summary lists exactly what to do, in order. That is step 5 below, and it is the normal ending for a first install.Gentian OS — Install Complete— handover is done too. Nothing is left.
If a step fails, the run stops there and names the command, the step file and
the call stack. Nothing after a failure runs, so the install never reports
success over a broken step. Fix the cause and run ./install.sh again.
Nothing secret is printed, and there is nothing to copy down: OpenBao's
initialization material goes to mode-600 files under ~/.gentian, and the
installer removes them once their contents are safe elsewhere.
Near the end, E-02-recovery-kit writes this cluster's recovery kit as
gentian-recovery-kit-<cluster-id>.age in the root of this checkout, asking
for a passphrase to encrypt it, and prints the path. That file is the one thing
you have to look after — step 5. By default it carries the break-glass signing
key too, so whoever holds the kit can write to the deployments repository when
the director cannot; set GENTIAN_KIT_INCLUDE_BREAK_GLASS=0 to keep that key
out of it.
Want to see the plan before anything runs? Both of these change nothing —
not the cluster, not the deployments repository (no commit, no push), not a
file or a key on this machine. Where the install would act, they print a line
beginning Would:
./install.sh --validate # which settings and credentials does this configuration have, and does this machine and cluster pass the pre-flight?
./install.sh --dry-run # what would the install do to THIS cluster?
Neither asks for a credential and neither writes the cluster's definition.
--validate checks the step files, prints a report of the configuration
ending in a line Result:, and then runs the install's pre-flight: the tools
on this machine, the cluster reachable and new enough, the operator image
published. It asks no question and runs no step's check, so it works before
the first install: a value the install asks for (MASTER_PASSWORD, the
cluster's domain) is listed as [PENDING], not as an error, and a cluster with
no definition yet is told that the install writes one. It stops after the
report, with exit status 1, when the report has an error. --dry-run goes
further and runs every step's check. Both read the
cluster, so it has to be reachable, and before the first install --dry-run
stops at clusters/<cluster-id>/kernel is incomplete, which is then the
expected answer. ./install.sh --explain needs neither a cluster nor a definition.
5. Handover
The install pauses here and waits for you. Three things finish it:
- Move the recovery kit somewhere safe. Step 4 wrote it in the checkout root and printed the path. Put it where your break-glass material already lives — a password manager, a sealed vault, offline media. Without it this cluster cannot be rebuilt as itself.
- Activate the platform admin's account and sign in.
admin@<kernel-domain>has no password: you set one through a single-use, expiring activation link, which also enrols a second factor unless step 0 switched that off. Where the kernel realm can already send mail, the installer mails the link to a recovery address —CLUSTER_ADMIN_RECOVERY_EMAILininstall.env, or the one it asks you for — or, without one, prints it here once. Where it cannot yet — a cluster with a mail relay, whose credentials you supply only after this sign-in — the link is printed here once, and an address frominstall.envis kept as the recovery address. Open it, set the password, then sign in athttps://platform.<kernel-domain>/. Nobody else, the installer included, ever knows the password. - Open the Credentials tab. Once signed in, open the Credentials
tab. Opening it is what the installer waits for: it shows that someone
other than the installer reaches the credential store. Then fill in what
the cluster is still missing, if anything — the SMTP relay's user name and
password on a cluster with
mail.serviceMode: external, any extra app repository and its pull secret. A cluster with its own mail (system) has no relay to supply, and often nothing at all.
Signing in and opening that tab is what the installer is waiting for: it proves
someone other than the installer can write credentials. The moment it sees that, it revokes its own
credential, deletes the temporary secret files, and prints Install Complete.
With a relay, do the SMTP relay first. It is not only about sending mail. Any app whose
profile asks for SMTP reads those credentials from OpenBao, and they are written
there only once the relay exists — so until you supply it, those apps do not
install at all: the tenant stays in Provisioning and the app's secret never
syncs. On a cluster with mail.serviceMode: external this is the most common
reason a tenant appears to hang.
The wait is bounded — 30 minutes, GENTIAN_HANDOVER_WAIT_SECS to change it —
and interrupting it costs nothing. Sign in whenever you like, then:
./install.sh --only E-03
On a multi-tenancy cluster the install ends here. It has created no tenant for users; that is the platform admin's next step (step 7).
On a single-tenancy cluster one step follows, E-04-user-tenant, in the
same run and with nothing for you to start. The cluster's one tenant for users
— always named user — has been in the deployments repository since step 0
and was refused all along: the cluster admits no tenant but the platform's
until the platform admin has signed in. Now that you have, the installer asks
Argo CD to try it again, waits for the tenant to be Ready (15 minutes,
GENTIAN_USER_TENANT_WAIT_SECS), and hands over its administrator's account
the way yours was: an activation link for user-admin@<kernel-domain>, mailed
when the tenant's realm can send mail and shown once otherwise. The install
waits for nothing after that and closes by naming both roles:
- the platform admin, in charge of the platform:
https://platform.<kernel-domain>/ - the user admin, in charge of the users and the user tenant:
https://desktop.<kernel-domain>/
They are two accounts in two realms, and may or may not be the same person.
What the user tenant puts on the internet, and its egress, is approved by
the platform admin; the platform admin can make the user admin its perimeter
approver too (kubectl gentian platform-roles holders add perimeterApprover user user-admin@\<kernel-domain> --reason \<why>).
If the tenant is not Ready within the wait, the install says what it is
waiting for and does not fail: kubectl get tenant user shows its state, and
./install.sh --only E-04 — or kubectl gentian tenants activate-admin user,
signed in as the platform admin — issues the link once it is.
6. Check the status
./install.sh --status
Every step reads satisfied, except steps that do not apply to this cluster —
those read undefined, and undefined is never a failure. E-01-tenants
always reads that way (it only acts on an uninstall), and E-04-user-tenant
does on a multi-tenancy cluster, where the install creates no tenant; B-09 and
D-04 do so on a cluster without OIDC, C-03 on one with no DNS provider, and
B-10 until the signing keys exist in the deployments repository. After the
handover B-08, B-09, B-10 and D-04 read undefined too: their checks
ask OpenBao, and the installer's token for it is revoked.
make check-credentials
Each credential requirement reports satisfied, unset-but-optional, or missing.
This reads External Secrets Operator's sync status, so missing means the value
is genuinely absent from OpenBao.
The installer finishing is not the same as the cluster being ready — Crossplane and Argo CD keep reconciling after it exits:
kubectl get managed
kubectl get application,applicationset -n kernel-gitops
The cluster is ready for a tenant
All of these hold on a cluster you can give a tenant to. If one does not, that is the thing to look at before creating a tenant:
-
The install printed
Install Complete, and./install.sh --statusshows every stepsatisfiedorundefined. -
https://platform.<kernel-domain>/shows the platform admin's desktop after you sign in asadmin@<kernel-domain>, andhttps://admin.platform.<kernel-domain>/opens the admin console. -
The handover is complete:
kubectl get configmap gentian-handover -n kernel-control \-o jsonpath='{.data.bootstrapCredentialRevoked}{"\n"}' # true -
Every Application is
SyncedandHealthy, and nothing Crossplane manages is unready:kubectl get application -n kernel-gitopskubectl get managed | grep -w False # prints nothing -
The CLI reaches the director as you:
kubectl gentian loginkubectl gentian tenants list # the platform tenant, and no errorkubectl gentian catalogues list # gentian, for every tenant, at an address that answers -
The catalog is published. Take the address from the line above:
curl -sI <address>/index.yaml | head -1 # HTTP/2 200
7. Tenants
A cluster's users live in a tenant of their own. The platform tenant, which
every cluster has, holds the platform admin and the platform's own components
and nothing else: it takes no apps, and the director says so if you try. How
many tenants for users a cluster has is its tenancy mode, which step 0
asked for (tenancyMode on the Cluster claim).
Multi-tenancy (multi, the default) | Single-tenancy (single) | |
|---|---|---|
| Tenants for users | any number; you create them. One may be a tenant people sign up to themselves (--self-sign-up, for customers of apps that serve the public: commands.md) | exactly one, named user; the install creates it (step 5) |
| Its desktop | https://desktop.<tenant>.<kernel-domain>/ | https://desktop.<kernel-domain>/ |
| Its admin console and apps | admin.<tenant>.<kernel-domain>, <app>.<tenant>.<kernel-domain> | admin.<kernel-domain>, <app>.<kernel-domain> |
| In charge of it | a tenant admin, admin@<tenant>.<kernel-domain> | the user admin, user-admin@<kernel-domain> |
https://<kernel-domain>/ and www. | a page that asks for an e-mail address and sends each person to their tenant | the user tenant's desktop |
desktop.<kernel-domain> | leads to that page | the user tenant's desktop |
| The platform admin | admin@<kernel-domain> at https://platform.<kernel-domain>/; admin console at admin.platform.<kernel-domain> | the same |
Your website can live at the main address. On a single-tenancy cluster
that is the user tenant's right; on a multi-tenancy cluster the main address
is the sign-in form unless the claim names a tenant for it
(mainAddress.tenant, set by you: a script there reaches every tenant's
sign-in, so name a tenant only where you control the site's scripts).
Install an app that offers a public website for it, then approve that surface
for the main address (kubectl gentian exposures approve, by whoever
approves public addresses in the tenant — the user admin, the members of the
tenant's perimeter group and the platform admin, step 8; it prints the rule
for a website there and needs --acknowledge-main-address-rule).
https://<kernel-domain>/ and www. then show the website. Sign-in is at
https://desktop.<kernel-domain>/, and https://<kernel-domain>/sign-in
always leads there.
On a single-tenancy cluster there is nothing to create. A second tenant
is refused, with a message that names the mode — in the console, by the CLI
and by the cluster itself. The tenant needs no domain bound either: it is
on the cluster's own domain already, so tenants domain is not part of this
path. (Binding a domain is refused there, with the reason: it would move the
tenant off the cluster's addresses, and the main address would not be its
own.) The user admin installs apps for the tenant user exactly as
described below for any tenant: wherever an example says --tenant acme,
it is --tenant user. A few host names are the platform's own there (id,
platform, www, argocd, headlamp, llm, mail, imap,
mail-egress, corp): an app that would answer on one of them is refused
at install, with the reason, and a component that reaches the cluster
another way says so on its status (HostReserved). As in every tenant on
every cluster, an app cannot take desktop, admin, store, console,
auth, login, signin, sign-in, sso, account or accounts
either.
On a multi-tenancy cluster the platform admin creates tenants through the
director — in the admin console, or with the gentian CLI, which is a
command-line client of the same director. Either way the director checks that
you may, commits the tenant to the deployments repository as you, and records
it.
In the console: Tenants → Bring a tenant on. Give it a name (any name
but default, which is reserved for the cluster-wide backup policy), a display
name, and optionally the administrator's recovery email; leave second factor
on unless you have a reason not to. After it is committed the screen waits for
the tenant to be provisioned and then hands the administrator account over:
the activation link is mailed to the recovery address, or shown once if you
gave none. Activate administrator on any tenant issues a new link later —
which is also how an administrator who lost access gets back in.
With the CLI: install it once, on whichever machine you administer clusters from, and sign in:
make install-plugin # kubectl-gentian + gtnctl into ~/.local/bin
kubectl gentian login # a code to confirm in the browser, as admin@<kernel-domain>
It talks to the director of the current kubectl context, so it needs no
configuration of its own. Re-run make install-plugin after pulling — the CLI
is a copy, not a link — and gtnctl version shows which copy answers.
kubectl gentian tenants create acme --display-name "ACME AG" # --no-mfa to skip the second factor
# --admins-add-catalogues its own admins may add catalogues to install from
# off unless given, and changed later with: kubectl gentian tenants set acme --admins-add-catalogues=true|false
kubectl gentian tenants activate-admin acme --recovery-email owner@acme.example
kubectl get tenant acme -w
activate-admin waits for the tenant to be provisioned, then mails the link,
or prints it once when you give no --recovery-email. The tenant is there
when kubectl get tenant acme reads Ready; a first tenant takes several
minutes, most of it the tenant's certificate and its realm. Then open the
link, set the password, and sign in at
https://desktop.acme.<kernel-domain>/ as admin@acme.<kernel-domain>.
The App Store
Apps are installed by the tenant's administrator from the App Store, or with
the CLI (below). The App Store is a tile on the tenant's desktop, shown to the
people who may install apps there — the tenant's administrators — and it
opens https://store.<the tenant's domain>/. It is an app on the cluster
that shows what a store elsewhere offers: its own server asks the store named
on the Cluster claim (spec.catalogue.storeUrl,
set from GENTIAN_STORE_URL) for the list, with no account,
and installs through the director as the person signed in.
The App Store app comes from the store's catalog,
with the Aluvian Center beside it and the Operations Console in the
platform tenant: step 0 places the three profiles, the extra services,
unless ./install.sh --no-extra-services (recorded on the claim as
spec.catalogue.extraServices: false) leaves them out. A tenant has the App
Store while four things hold, which a default installation arranges: the
profile is placed, the cluster reports its usage statistics, the claim names a
store, and that store is not an address a cluster's own App Store app could
have (store.<a domain a cluster is installed under>). Tenants created later
get it too. The platform tenant never has it. The install says which it is, on a line beginning
App Store app: in step D-03.
The store's catalogs are added for you. An app bought in the App Store
is installed from the catalog its entry names, and the director refuses a
catalog the cluster has no source for. So on every run step 0 reads the
store's GET /v1/meta and adds each catalog it lists that the claim has no
source of that name for, to spec.catalogue.sources with addedBy: store.
It never changes or removes a source: a name you have at another address is
kept and warned about, and one the store stops listing stays until you remove
it. A store that does not answer is a warning. This trusts the store's list
of addresses; every bundle is still held to the digest of its install
(docs/guides/custom-catalogs.md §1).
When the tile is missing, or the App Store lists nothing, check in this order:
-
Is the app placed?
kubectl get component -A | grep app-storeshows one for the tenant. If it does not, firstkubectl get componentprofile app-store-pe: none means step 0 did not place it (--no-extra-services, or the catalog's index did not list it yet; step 0 says which). Otherwise:kubectl get configmap gentian-tiles -n kernel-control -o jsonpath='{.data.tiles\.yaml}' | grep -A2 '^appStore:'reason: usage-statistics-disabled— the cluster does not send its usage statistics report, which the App Store needs; setGENTIAN_NO_USAGE_STATISTICS=0ininstall.envand run./install.sh.reason: no-store-configured— the claim names no store, or not anhttps://address; addspec.catalogue.storeUrltoclaims/cluster.yamlin your deployments checkout and run./install.sh.reason: store-address-is-own-host— the claim names the App Store app's own address; name the store's API instead. -
Are you signed in as someone who may install? The tile is shown to the tenant's administrators only.
-
Does the store answer? The page says "The store could not be reached" when it does not. From any machine:
curl -s https://<store address>/v1/meta # the address on your claimcurl -s 'https://<store address>/v1/apps?limit=5'Both must answer
200with JSON, without a redirect and without a login. A name that does not resolve, or any other answer, is the store's side to fix: nothing on this cluster changes it, and the App Store's Installed tab and the CLI keep working meanwhile. To use another store, changespec.catalogue.storeUrl(GENTIAN_STORE_URLininstall.envsets it for a new claim). -
Does the store list anything? "The store lists nothing that matches" means the second request above answered with an empty list: the store is up and holds no apps yet.
The App Store and the CLI are independent. Everything below works on a cluster with no store at all.
Installing apps with the CLI
On a single-tenancy cluster the tenant is user: read --tenant user for
--tenant acme below.
kubectl gentian apps list --tenant acme --available # what the tenant's catalogues offer
kubectl gentian apps install nextcloud-base-ce --tenant acme # the build the catalog lists, pinned by digest
kubectl gentian apps list --tenant acme # what the tenant has
install takes the entry from a catalog the tenant sees and pins it to the
digest that catalog lists; see docs/reference/commands.md §6 for
--from and --digest. A new cluster has one catalog, gentian, for every
tenant: the released one when the cluster is installed from a release, the
development one (…/gentian-apps/develop) when it is installed from a branch
(docs/guides/custom-catalogs.md §3). To offer your own apps, publish a catalog and add it for the cluster
or for one tenant:
kubectl gentian catalogues list
kubectl gentian catalogues add acme https://acme.github.io/acme-catalogue # for every tenant
kubectl gentian catalogues add acme https://acme.github.io/acme-catalogue --tenant acme # for acme only
A catalog whose profiles must not be public is an OCI artifact instead, read with the credential of a repository declared for its owner (step 8):
kubectl gentian catalogues add acme --oci ghcr.io/acme/catalogue:1 --repository acme --tenant acme
A catalog for the whole cluster can also be declared before the install,
as one more entry under spec.catalogue.sources in claims/cluster.yaml
(a name, and an https url or an oci artifact with its repository).
docs/guides/custom-catalogs.md explains how to build one, step by step.
To remove a tenant, kubectl gentian tenants retire acme (or Retire in the
console) — not kubectl delete: git is what the cluster reconciles towards, so
deleting the object just brings it back. Retire keeps its data; add --purge
(or choose Purge in the console) to delete it.
A tenant's data is the tenant's. Export in the console (or a scheduled
backup through the Operations Console) produces one encrypted bundle, which
Download hands over as a single .gentian file. The way back is one
command, on this cluster or any other:
kubectl gentian tenants import acme-export-20261001.gentian --identity-file acme-backup-key.txt
It declares the tenant from the bundle's own manifest, waits for the operator
to provision it, restores the data, and says when people can sign in again
(members need a password reset until bundles carry credentials; activate the
administrator with tenants activate-admin). On a single-tenancy cluster an
import is refused unless the tenant it makes is named user (--name user)
and the cluster has none.
8. A cluster that runs your own services from a private repository
Everything above holds unchanged when the deployments repository is private and the cluster's first apps are your organization's own services. What differs is listed here, in the order you meet it.
-
Point the install at the private repository. In
install.env:GENTIAN_DEPLOYMENTS_REPO=https://github.com/<organization>/<deployments-repository>GENTIAN_DEPLOYMENTS_BRANCH=mainGENTIAN_DEPLOYMENTS_AUTH=basic # the default: a user name and a tokenGENTIAN_DEPLOYMENTS_CLUSTER_ID=<the directory under clusters/>GENTIAN_DEPLOYMENTS_STAGE=prodClone it to
~/.gentian/gentian-deployments(step 1) and have a token that can write to it: the installer pushes with it, and so does the director for every tenant and app afterwards.The installer gives that token to Argo CD as well, before anything in the cluster can: the claim that hands Argo CD the repository's credential from the vault is itself a file in the repository. You do nothing for it.
A-06-argocdregisters the login the install collected (a Secret namedargocd-repo-creds-bootstrap-deployments, beside Argo CD), andC-05-repository-handoffremoves it once Argo CD holds the same login from the vault. IfC-02-appsetswaits ongentian-claims-prodand Argo CD reportsauthentication requiredfor the repository, that Secret is what is missing: checkGENTIAN_DEPLOYMENTS_AUTHis notnone, and run./install.sh --only A-06with the token. -
Install and hand over (steps 4 to 6). On a multi-tenancy cluster, then create the tenant your services run in (step 7):
kubectl gentian tenants create <tenant>. On a single-tenancy cluster there is nothing to create: the tenant isuser, stepE-04made it after the handover, and<tenant>below isuser. Do not commit a tenant's manifest yourself beforehand: the cluster refuses every tenant until the platform admin has signed in, Argo CD gives up on it after about a quarter of an hour, and the director refuses to create a tenant whose directory is already there. -
Make your profiles a catalog. A catalog is
index.yamlandprofiles/<name>.yaml, as docs/guides/custom-catalogs.md §4 describes, with the index generated byscripts/tools/build-catalogue-index.py. A cluster reads one from two places and from nowhere else:-
An OCI artifact, for profiles that must not be public: the same files packed into one layer and pushed to a registry, read with the credential of a repository declared for the tenant (5) (custom-catalogs.md §4.4). Never a directory of the deployments repository, which holds the cluster's state only.
-
A public https address, without a login and without a redirect. The raw-file address of a private repository is not one. The profiles carry no secret — what is private stays in the registry (5) — so the directory can be published as static files. Check it from outside:
curl -sI https://<catalog address>/index.yaml | head -1 # 200curl -sI https://<catalog address>/profiles/<profile>.yaml | head -1 # 200
-
-
Add the catalog for that tenant only, so no other tenant sees it, signed in as the platform admin:
kubectl gentian catalogues add <name> --oci <host>/<path>:<tag> --repository <repository> --tenant <tenant> # an artifactkubectl gentian catalogues add <name> https://<catalog address> --tenant <tenant> # or an addresskubectl gentian apps list --tenant <tenant> --availableA catalog added for one tenant brings profiles alone. A profile that needs a Composition, an OIDC pack or a sign-in handler of its own has to come from a catalog added for the whole cluster (leave
--tenantout). -
Let the tenant pull your private charts and images. Declare the registry for the tenant and set its password, once: Credentials → Add a repository in the tenant's admin console (docs/guides/custom-catalogs.md §8). A chart whose address lies inside the declared registry is then pulled with it. Without this the install is committed and the app never arrives.
-
Install the service:
kubectl gentian apps install <profile> --tenant <tenant>kubectl gentian apps list --tenant <tenant>kubectl get component -A | grep <profile> # Ready when it runs -
Its public address. An entry a profile exposes answers at
<subDomain>.<the tenant's domain>. An entry that the public reaches (surface: perimeterin the profile) is not reachable until the tenant's approver has published it.On a single-tenancy cluster the tenant's domain is the cluster's own: installed on
example.com, a service withsubDomain: apianswers atapi.example.comwith nothing bound and notenants domaincommand. The wildcard record and certificate for*.example.comare the cluster's. The names the platform keeps there cannot be asubDomain(step 7). An entry a profile declares for the main address (apex: true) is the cluster's website athttps://example.com/andwww., approved with--acknowledge-main-address-rule(below).On a multi-tenancy cluster the tenant's domain is
<tenant>.<kernel-domain>unless the tenant is bound to a domain of its own. A service that must answer atapi.example.comtherefore needs three things: the tenant bound toexample.com;*.example.compointing at this cluster; and the cluster's certificate issuer able to answer DNS challenges for that zone (the DNS token of step 3 covering it). A domain on or under the cluster's own is refused.Binding the domain is one command, for whoever may configure the cluster. It prints what moves and what has to be true first, and asks for the domain typed out. Run it straight after creating the tenant (2), before anything is installed: a tenant bound later moves every address it already has.
kubectl gentian tenants domain <tenant> example.comkubectl gentian tenants domain <tenant> # what git declares, and how to see that the cluster took it inkubectl gentian tenants domain <tenant> --remove # back to its default addressPublishing the entry is two commands, for whoever may publish in the tenant (docs/guides/app-customization.md §2.10). That is the platform admin, in every tenant the cluster operates, and the perimeter approvers the platform admin names on the Cluster claim: an administrator of the tenant, for that tenant, or a person of the platform tenant, for every tenant. The tenant's own admin does not approve unless named:
kubectl gentian platform-roles candidates # who could be namedkubectl gentian platform-roles holders add perimeterApprover <tenant> <login> --reason <why>kubectl gentian platform-roles holders # who holds which roleAnybody else is refused with 403. The first lists what the tenant's installed apps ask to have on the internet: each entry's address, its paths, whether anybody signs in, and whether it is approved. The second shows one entry again and approves it once you type its name:
kubectl gentian exposures requests --tenant <tenant>kubectl gentian exposures approve <app> <entry> --tenant <tenant> [--expires <date>] [--reason "<why>"]An entry for the cluster's main address is approved only for the one tenant that may hold it (the tenant
useron a single-tenancy cluster, or the tenantmainAddress.tenanton the claim names), and only with--acknowledge-main-address-ruleadded: the command prints the director's rule for a website there, and the flag says the rule is true of this site.--yesdoes not stand in for it.An entry the list does not show cannot be approved: the director refuses an app that is not installed in the tenant and an entry its profile does not declare for the internet. The admin console shows the same entries, read-only, under Apps → Details → Public addresses. What the tenant has published, and taking an entry down again:
kubectl gentian exposures list --tenant <tenant>kubectl gentian exposures withdraw <app> <entry> --tenant <tenant> -
Check it from outside:
curl -sI https://<its address>/<a path the entry serves>answers from your service. When that service is the store other clusters name asspec.catalogue.storeUrl, the two requests under The App Store in step 7 are the check.
Advanced install options
None of this is needed for a normal install. Skip it unless one of the headings is the problem you have.
Installing unattended
Set GENTIAN_NONINTERACTIVE=1 and GENTIAN_KIT_RECIPIENT (an age public
key; without it E-02 cannot encrypt the recovery kit and stops) in
install.env, and supply the bootstrap credentials through the environment
instead of the prompt:
| Credential | Environment variable |
|---|---|
deployments-repository | GENTIAN_DEPLOYMENTS_GIT_USERNAME, GENTIAN_DEPLOYMENTS_GIT_TOKEN |
master-password | MASTER_PASSWORD |
gentian-os-repository, gentian-ui-repository | GENTIAN_OS_GIT_USERNAME / GENTIAN_OS_GIT_TOKEN, and the same with UI |
| Cloudflare API token (DNS) | CF_API_TOKEN |
| Cloudflare Tunnel API token | CF_TUNNEL_TOKEN |
| Another DNS provider's credential | GENTIAN_DNS_<FIELD>: the field's name in kernel/platforms.yaml, in capitals, with _ for - and . |
The kernel namespaces' NetworkPolicies are on by default, and step A-01
refuses a cluster whose CNI does not enforce them. They restrict what may
connect into a kernel namespace and nothing a pod connects out to
(reference/install.md §9).
Step 0's questions take their defaults unattended, and an environment value
answers any of them without a question: KERNEL_DOMAIN (no default — must be
set), NETWORK_MODE, NODE_IP, CERT_ISSUER_MODE, ACME_ENV,
DNS_PROVIDER, MAIL_SERVICE_MODE, EXTERNAL_SMTP_HOST, PLATFORM,
STORAGE_CLASS, TENANCY_MODE, SECRET_MODE, CLUSTER_ADMIN_REQUIRE_MFA,
BACKUP_ESCROW_IDENTITY, LLM_SUPPORT, GPU_ACCELERATION. Export them
for that first run and do not write them into install.env: the claim owns
them from then on, and a value left in the file overrides it. The handover wait is skipped, so the run
ends at Almost There and --only E-03 finishes it once the platform admin
has signed in. On a single-tenancy cluster (TENANCY_MODE=single) the user
tenant is created after that sign-in and not before: ./install.sh --only E-04, or a plain re-run, asks Argo CD to sync it, waits for it, and issues
the user admin's activation link — shown in its output, or mailed when the
tenant's realm can send mail. kubectl gentian tenants activate-admin user [--recovery-email \<address>], signed in as the platform admin, issues one
from anywhere.
The installer reads the environment first, then its cache, then OpenBao, and prompts for whatever is still missing — so a partly-supplied environment still works interactively.
There is no secrets file.
The bootstrap credential cache
Between being typed and reaching OpenBao there is a window where an install can
fail with nothing to recover from. Validated answers are cached at
~/.gentian/bootstrap-credentials.env — 0600, in a 0700 directory — and
B-08-seed-secrets deletes it once OpenBao holds them.
To keep credentials in the process only, and retype on every resumed run:
GENTIAN_NO_CREDENTIAL_CACHE=1 ./install.sh
Put it in install.env to make that permanent for the machine. That file is
read before any credential is collected, so anything set there applies to the
whole run.
The cache's first line names the cluster it belongs to. A run for another cluster neither reads it nor writes it.
Running the installer for more than one cluster from one machine
Keep one checkout of gentian-os, with its own install.env, and one terminal
per cluster. Put each cluster's paths in its own install.env
(GENTIAN_DEPLOYMENTS_PATH, GENTIAN_CREDENTIAL_CACHE, GENTIAN_GPG_HOME)
and point kubectl at the cluster with kubectl config use-context. A
variable exported in a shell beats install.env for every run started from
that shell, so a terminal that sourced a second cluster's settings carries
them into the first cluster's run.
Before it reads, caches, writes or applies anything for a cluster, the
installer checks that its settings, its deployments checkout and the cluster
kubectl reaches are one cluster, and stops otherwise:
- the checkout's
originand branch have to beGENTIAN_DEPLOYMENTS_REPOandGENTIAN_DEPLOYMENTS_BRANCH; - a repository that already defines other clusters gets no new
clusters/<id>unless you answer yes to a question that names it, or setGENTIAN_ADD_CLUSTER=<id>for an unattended run; - on a cluster that is already installed, its Cluster object has to have the
name and kernel domain of the claim in
clusters/<id>; - a derivation salt that differs from the cluster's stops the run when the cluster's identity cannot be confirmed, and a credential cache that belongs to another cluster is never read or written.
The run starts by listing the settings the shell exports over install.env,
by name. The message it stops with names the kube context, the API server,
the cluster the definition describes, the cluster that answered and the
settings it loaded, and says what to do: usually open a new terminal, or
unset what it lists, or kubectl config use-context <context>.
--dry-run, --validate and --status report the same and exit 1.
--force does not get past any of it. The checks and the two confirmations
are in reference/install.md §4, One run, one
cluster.
Exporting a recovery kit from a job
age -p prompts on the terminal, so an unattended export needs a public key
instead, and reading the kit back needs the matching private key:
GENTIAN_KIT_RECIPIENT=age1... ./install.sh --export-recovery-kit kit.age
GENTIAN_KIT_IDENTITY=~/.age/key.txt ./install.sh --recover kit.age
The usage statistics report
The cluster tells a report address once a day what it runs: its tenants, how many accounts each has, and the apps installed through the App Store — no personal data. What is sent, and where to read the last report, is in docs/concepts/operations.md §6.2.
Which Crossplane resource types exist
A fresh install creates only the provider resource types the platform uses —
25 of the 374 the vault and Keycloak providers ship — because every installed
type costs API-server memory on a managed control plane. kubectl get mrap
shows the policy that lists them (gentian-platform) and kubectl get mrd
every type with whether it is active. Crossplane does not take a type away
from a cluster that has it.
If a fresh install stops in B-05 naming a type that did not appear, or a
composite waits on a kind the API server does not know, set
CROSSPLANE_ACTIVATE_ALL=true in install.env and run the installer again:
that installs every type. The list, and how to add a
type to it, are in docs/reference/install.md §4.
Running one step, or stopping early
./install.sh --explain # what each step does, in order
./install.sh --status # which steps this cluster has already satisfied
./install.sh --only B-08 # a single step
./install.sh --from C-01 # resume from a step
./install.sh --until D-01 # stop after a step
./install.sh --phase secrets # one phase: control-plane, secrets,
# platform, applications, handover
Re-running the whole installer is safe: each step checks the cluster and skips what is already done, so convergence and update are the same operation.
Next steps
- Add more tenants (multi-tenancy) — repeat step 7. Day-to-day operations are in docs/reference/commands.md.
- Configure mail — docs/concepts/mail.md. Mail between
users of this cluster works once the cluster's own mail stack is deployed
(
mail.serviceMode: system). Mail to and from the internet arrives on the load balancer of the mail edge insystem-mail-dmz— a proxy that passes ports 25, 587 and 993 on to Postfix and Dovecot with TLS untouched, §9b — and additionally needs the MX, SPF, DKIM, DMARC and PTR records described in §10 DNS for real mail — including the Cloudflare rule that MX records must stay DNS-only, never proxied. With a DNS provider the cluster publishesmail.<kernel-domain>andimap.<kernel-domain>from that load balancer's address, and the MX, SPF, DKIM and DMARC records; the PTR is set at your provider. - Change this cluster's configuration — edit
claims/cluster.yamlin your deployments checkout and run./install.sh: step 0 commits the edit signed and pushes it, and the cluster reconciles. docs/guides/deployment.md explains the layering. - Understand the architecture — docs/overview/architecture.md and docs/concepts/kernel.md.
- Back up the data — the recovery kit covers credentials, not databases or object storage.
Notes
Things worth knowing, and what to do when something looks wrong.
The recovery kit
Most of a cluster is reconstructible: configuration comes from Git, and every derived credential is a function of the master password and the derivation salt. The salt is generated during the install and lives only in the cluster. Lose it and the same master password reproduces nothing.
The kit closes that gap — the salt, the master password, the OpenBao recovery
key, the backup key, the break-glass signing key and this cluster's identity,
in one encrypted file. Restore it into a fresh cluster and every derived
credential comes back byte-identical. That holds for secretMode: derived.
On a random cluster the kit holds none of the credentials drawn at random:
a rebuild from it makes new ones, and they come back only from OpenBao's
daily snapshot (recovery.md §4) (step 4):
./install.sh --recover <kit> # on the fresh cluster, before anything else
Without it a rebuild gives you a working cluster with entirely different credentials, which is a migration rather than a restore. The kit does not back up your data, and it does not restore OpenBao itself — a fresh instance issues its own unseal material.
It is encrypted with age, which pre-flight requires — openssl remains only
as a fallback for a kit exported on a machine that somehow lacks it;
there is no unencrypted path. Both ask for a passphrase, so an unattended
install needs GENTIAN_KIT_RECIPIENT set to an age public key instead.
To write another one at any time:
./install.sh --only E-02
Why handover is gated
The installer's credential can write every secret in the cluster. Revoking it
before anyone else has demonstrably written one would leave a cluster nobody
can supply a credential to; revoking it with no recovery kit would mean that
if the login path later breaks, there is nothing to fall back on. Either gap
alone makes the recovery "re-initialize OpenBao from scratch", so the
revocation waits for both. Until it happens, creating tenants is held back.
There is no exception for the install's own: on a single-tenancy cluster the
user tenant's manifest is committed in step 0 and the cluster refuses it like
any other until the platform admin has signed in, which is why E-04 comes
after the handover and has to ask Argo CD to try the manifest again.
Where a cluster stands:
kubectl get configmap gentian-handover -n kernel-control -o yaml
writePathProven: "true"— someone has signed in and the exchange worked.recoveryKitExported: "true"— a kit has been written.bootstrapCredentialRevoked: "true"— handover is complete.
Signing in records nothing
Open the admin console and select the Credentials tab, which performs the same token exchange as the login.
The administrator has no password
Neither the platform admin nor any tenant admin (or the user admin of a single-tenancy cluster) is given one. Each account is created without a password and handed to its holder through a single-use, expiring link that sets one (and enrols a second factor, unless switched off) — the same way every member is invited. The installer and the director never know it, so there is nothing to print, derive or recover.
Lost access, an expired link, or a link nobody received, for a tenant's administrator:
kubectl gentian tenants activate-admin acme # a tenant administrator
or Tenants → Activate administrator in the console. Each call issues a new link; earlier ones still expire on their own.
The platform admin's own account is not handed over that way. The CLI and the console ask the registrar, and the registrar changes nothing about a member of the platform administrators' group, whoever asks: it answers 403. That account's link comes from the install host:
./install.sh --activate-admin # a new link for admin@<kernel-domain>, from the installer's own credential
which is also the way back in when no administrator can sign in at all.
A commit to the deployments repository is not synced
Argo CD syncs that repository only while its newest commit is signed by the
director or the break-glass key — the two ids in
clusters/<cluster-id>/kernel/signing/keys.env. A commit made with your own
key, or unsigned, sits at the head of the branch and stops every sync.
- A change to the cluster's own files (
clusters/<cluster-id>/kernel,tenants/platform,catalogue): undo the commit, make the change in your checkout instead, and run./install.sh. Step 0 commits it signed with the break-glass key. profiles/_base.yamlor the profile of this cluster's stage: the same. Step 0 commits both, signed.- A file anywhere else in the repository — another stage's profile:
step 0 does not commit those. Commit and push it
yourself and run
./install.sh(or./install.sh --only A-01: step 0 runs before the first step of every install run): it finds a head it does not trust and puts an empty commit signed with the break-glass key on top, sayingSigned the head of the deployments repository (break-glass). The same after every later change to such a file.--dry-runand--validatesign nothing.
On another machine, ./install.sh --recover <kit> imports that key into
~/.gentian/gnupg first.