Installation on Kubernetes with Helm
Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated
nauthera-server has a Helm chart at deploy/ in its repository.
It deploys the server with cert-manager issuing every certificate and key it needs,
including the JWT signing keys and the key that signs the audit log's checkpoints.
Release status. A version tag publishes, cosign-signed, to the GitHub Container Registry: the server image
ghcr., the operator imageio/ nauthera/ nauthera-server ghcr., and the chart atio/ nauthera/ nauthera-operator oci:, all three with the same version. They are public, so anyone can pull them without a login or a pull secret; Downloads lists every version. Nauthera is pre-1.0, so a release can still change APIs and values. The source repository is still private./ / ghcr. io/ nauthera/ charts/ nauthera-server
Requirements
- Kubernetes 1.27 or newer
- Helm 3 or 4
- cert-manager, when
certManager.enabledis on: it is off by default, and every profile butkind-append-only.yamlturns it on - The Prometheus Operator CRDs, if you enable
metrics.serviceMonitorormetrics.prometheusRule - The Gateway API CRDs and a Gateway, if you enable
httpRoute - PostgreSQL, the only database the server supports, plus a runtime role created
beforehand for
appendOnlyAudit - Redis, for any issuer whose host is not loopback
From the published chart
Install a published version straight from the registry. No helm registry login and
no image pull secret are needed, because the chart and both images are public:
VERSION=<version> # the newest is at the top of /downloads
helm install nauthera oci://ghcr.io/nauthera/charts/nauthera-server --version "$VERSION" \
--namespace nauthera --create-namespace --values my-values.yamlPin --version, so an install and every later upgrade name the release they run. Helm
passes over release candidates (-rc.N) unless you name one. To read the chart's values, its README and its profiles before installing, pull it:
helm pull oci://ghcr.io/nauthera/charts/nauthera-server --version "$VERSION" --untar
ls nauthera-server/profiles # kind.yaml, production.yaml, operator.yaml, …A released chart's version and appVersion are the server's version, and its values
pin both images by digest. image.digest wins over image.tag, so setting a tag alone
does not change the image a released chart runs: set another build's digest, or empty
digest. To run from your own registry, mirror the images and set image.repository,
operator. and imagePullSecrets, which applies to the server, the
operator and the database-maintenance pods.
Each image and the chart are signed keyless with cosign by the release workflow run, and the signatures are recorded in Sigstore's public transparency log. Verify one against the workflow and the tag:
cosign verify "ghcr.io/nauthera/charts/nauthera-server:$VERSION" \
--certificate-identity "https://github.com/nauthera/nauthera-server/.github/workflows/release.yml@refs/tags/v$VERSION" \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe same check works for ghcr. and
ghcr., anonymously. Before they are signed, the images are scanned
with Trivy and the release fails on a fixable high or critical finding. Each image is
built for linux/amd64 and linux/arm64 and carries a build provenance attestation and
an SBOM. Each version's page on GitHub Packages shows its digest.
From a checkout instead, install deploy/ and build the images
yourself, as the kind recipes below do.
Local: kind
The Quick Start runs the published chart on kind, with no checkout. From a checkout of nauthera-server, the recipes build the image instead:
just kind-up # kind cluster, cert-manager, image built and loaded, chart installed
just kind-check # discovery, JWKS, the sign-in page, helm test and the audit checkpointskind-up applies deploy/, a throwaway PostgreSQL whose data lives in
an emptyDir, and installs the chart with profiles/kind.yaml. That profile runs one
replica that migrates the database on start, no Redis, in-pod TLS from the chart's own
CA, no ingress controller, and a NodePort that kind publishes on
https://localhost:8443. Expect a certificate warning in the browser,
because the CA lives inside the cluster. Tear it down with just kind-down.
Production
profiles/production.yaml is the production shape: three replicas behind an ingress,
PostgreSQL, Redis over TLS, file-backed keys, a PodDisruptionBudget, a NetworkPolicy, and
a ServiceMonitor with alerting rules. Fill in the host and the Secret names:
# my-values.yaml
image: # only for your own build; a released chart pins its image
repository: registry.example.com/nauthera-server
digest: sha256:… # pin a digest, not a tag
ingress:
host: auth.example.com
certManager:
issuerRef:
name: letsencrypt # governs the ingress certificate only
kind: ClusterIssuer
config:
issuer:
url: https://auth.example.com
cache:
mode: sentinel
addresses: ["redis-sentinel-0:26379", "redis-sentinel-1:26379", "redis-sentinel-2:26379"]
master_name: mymaster
security:
trusted_proxies: ["10.244.1.7/32"] # your ingress pods, not a whole range
client_ip:
mode: x-forwarded-for
secrets:
databaseDsn:
existingSecret: nauthera-pg-app # e.g. the Secret CloudNativePG publishes
existingSecretKey: uri
cachePassword:
existingSecret: nauthera-redis
mfaEncryptionKey:
existingSecret: nauthera-keys
keysEncryptionKey:
existingSecret: nauthera-keysThe Redis behind config.cache must not evict keys (maxmemory-policy noeviction; the
server refuses to start otherwise), should persist with AOF, and should be replicated:
cache.mode sentinel or cluster. The production profile ships a single address;
replace it as above. See Backups & Upgrades.
helm upgrade --install nauthera deploy/helm/nauthera-server \
--namespace nauthera --create-namespace \
-f deploy/helm/nauthera-server/profiles/production.yaml \
-f my-values.yamlThe production profile also sets config., so the
audit log's checkpoints are never exported unsigned (see
Certificates).
Gateway API
httpRoute publishes the public listener through a Gateway API HTTPRoute, instead of
the Ingress or beside it while you move over:
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway-system
sectionName: https # the HTTPS listener; a plain-HTTP one serves sign-in in the clear
hostnames:
- auth.example.com # the issuer, unless ingress.host or config.issuer.url names oneThe chart attaches to a Gateway and never creates one, because a Gateway and the
certificate on its listener serve every route attached to it. TLS terminates at the
Gateway, so certManager.issuerRef plays no part. A Gateway in another namespace must
admit the release namespace in that listener's allowedRoutes. The server sees the
Gateway's proxy as the peer, so config. names the proxies' pod
addresses and networkPolicy.httpFrom the namespace they run in. The default rule routes
the whole origin to the public port, which the server needs: discovery, every endpoint
and the sign-in UI sit at the root.
With certManager.httpTLS the pod serves HTTPS itself. The chart then renders a
BackendTLSPolicy that verifies the pod's certificate against a CA read from the
ConfigMap named in httpRoute. (key ca.crt), and refuses to
render without one. cert-manager writes the chart's CA only into a Secret, so copy it
into a ConfigMap, or have trust-manager keep a copy. profiles/ is a
local install through a Gateway you already run.
The main organization
The main organization's settings can be set from the chart, under config.orgs.main.
Each setting given is applied at every startup, and the admin API then refuses to change
it; the ones left out stay editable in the dashboard. FAPI mode is read only at startup,
so this is the way to change it for the main organization. branding is text (YAML or
JSON), because the configuration loader lowercases a nested object's keys:
config:
orgs:
main:
display_name: Acme Platform
fapi_mode: true
email_verification_required: true
branding: |
productName: Acme
logoUrl: https://acme.example/logo.svgSee Configuration.
What the chart checks for you
Most of the server's startup checks hinge on one value, the issuer URL. It must be
https with a host and no path. If the host is not loopback (localhost and
*.localhost count as loopback), the server also requires a shared cache and file-backed
signing and MFA keys. The chart runs the same checks when it renders templates, so a bad
combination fails helm install with the fix attached:
$ helm install nauthera . --set config.issuer.url=https://auth.example.com
Error: nauthera-server: issuer "https://auth.example.com" is a real, fronted
deployment (host "auth.example.com" is not loopback), and the server refuses to
start in this configuration:
* cache.enabled is false. Authorization codes and refresh tokens would fall
back to process memory, so a code issued by the replica that served
/authorize does not exist for the replica that serves /token …
Fix: set config.cache.enabled=true and config.cache.address.
…The chart also refuses a trusted_proxies entry that covers every address, a
client_ip.mode other than socket without trusted_proxies, a config.database.dsn
(it would land in the ConfigMap in the clear; use secrets.databaseDsn), no database
connection details at all, the image tag latest, rate_ without a
cache, the gRPC listener without either mTLS or allow_insecure, in-pod HTTPS behind a
route with no httpRoute., a disabled admin listener or an
overridden listener address, and, with config., no
checkpoint key.
values.schema.json rejects unknown keys in the chart's own settings and in the whole
config tree. Kubernetes objects it passes through unchanged, such as resources,
affinity and the security contexts, are not checked. The schema's config half is
generated from the server's Go configuration structs, so it cannot drift from what the
server reads.
Configuration and secrets
configmirrors the server's configuration tree one-to-one. It is rendered to a YAML file and mounted read-only. Set only what you mean to change. See Configuration.- Eleven keys under
secretshold the server's secret material:databaseDsn,databasePassword,cachePassword,cacheSentinelPassword,mfaEncryptionKey,keysEncryptionKey,smtpPassword,captchaSecret(a hosted captcha provider's secret key),auditCheckpointKey,mfaRetiredKeysandkeysRetiredKeys. A twelfth,ownerDatabaseDsn, is used only by the database-maintenance Job and CronJob (below). Each takes either an inlinevalue(development only) or anexistingSecretplusexistingSecretKey. secrets.delivery: file(the default) mounts eight of them as files (0440) and setsNAUTHERA_.SERVER_ <KEY>_ FILE auditCheckpointKeyis always mounted as a file, under either mode, because the server reads it from a path. The two retired-key lists always arrive as environment variables throughsecretKeyRef, because the server cannot read them from a file.envuses one explicitsecretKeyRefper variable.
Certificates
| Certificate | Purpose | Issued by |
|---|---|---|
| CA | Self-signed CA in the release namespace | itself |
| Signing key (EC P-256) | JWT signing key (ES256) | the chart's CA |
| Signing key (RSA) | RS256 key, which OIDC Discovery requires to be advertised | the chart's CA |
| Audit checkpoint key (Ed25519) | Signs the audit log's chain checkpoints | the chart's CA |
| Ingress | Browser-facing certificate | certManager.issuerRef, or the chart's CA |
| HTTP (optional) | In-pod HTTPS | the chart's CA |
| gRPC server and client (optional) | mTLS for the AdminService | a CA of the control plane's own |
The signing-key certificates use rotationPolicy: Never, because re-keying would
invalidate every token in flight. The server reads the gRPC certificate and client CA at
each handshake, so a renewal is taken into use by the next client to connect. It reads
the in-pod HTTPS certificate once at startup, so restart the pods after cert-manager
renews it. The gRPC client
certificate lives for 24 hours by default (certManager.); the
operator re-reads it on every call, so a short lifetime costs nothing.
The audit checkpoint key is the first of: a path you set in
config. for a key you mount yourself, secrets.
(a PEM PKCS#8 Ed25519 key, openssl genpkey -algorithm ed25519), or, with
certManager.enabled, the key cert-manager issues (certManager., on
by default), which every replica shares. Its rotationPolicy is also Never, because a
new key signs under an id the verifier does not trust yet. With none of them the
checkpoints are exported unsigned and the server warns at startup. The public half is the
issued Secret's tls.crt; add it to the trusted keys nauthera-server audit verify
reads. See Audit Log.
Sign-in alerts, password resets, email verification and invitations need an outbound
relay. Configure the deployment's own under config.mail (smtp.host, smtp.port,
smtp.tls, from.address) with the password in secrets.smtpPassword. An organization
can send through a relay of its own, listed under mailOrganizations:
mailOrganizations:
- slug: acme
smtp:
host: smtp.acme.example
port: 587
tls: starttls # starttls | implicit | none
from:
address: no-reply@acme.example
name: Acme
credentials:
existingSecret: acme-smtp # keys username and passwordThe credentials are read from an existing Secret, mounted as files and re-read at every
delivery, so rotating the Secret needs no upgrade or restart. They never reach the
ConfigMap or the database. The connection uses TLS (starttls on 587, implicit on
465). none sends in the clear; with credentials it also needs
smtp., and the server warns about it at startup.
IP geolocation
A new-sign-in alert can name the city and country a sign-in came from. Mount a
GeoLite2-City or GeoIP2-City database through geoip.volume (any volume source, such as
a PVC that geoipupdate writes to) and name the file in geoip.fileName. The server
re-reads the file when it changes.
Alerting
metrics. renders the server's alerting rules as a
PrometheusRule. They come in groups that can be switched off one by one under
metrics.: availability, errors, latency, dependencies,
audit, security, threats, logout, controlPlane, certificates, kubernetes
(the only one that reads kube-state-metrics) and operator, which reads the operator's
metrics and renders with operator.enabled and either
operator. or metrics..
The threats group fires
NautheraThreatSignalElevated and NautheraThreatSignalCritical when a threat signal's
hourly rate for one organization reaches the elevated or critical rate of the server's
threat catalog. Leave out single alerts under disabled, and tune an
alert's severity, for and threshold under alerts:
metrics:
prometheusRule:
enabled: true
labels: {release: kube-prometheus-stack}
additionalRuleLabels: {team: identity}
runbookUrl: https://runbooks.example.com/nauthera
rules: {kubernetes: false}
disabled: {NautheraRateLimitingHigh: true}
alerts:
NautheraServerHighLatency: {threshold: 0.5, for: 30m}The error-budget alerts burn against availabilityObjective (99.9 by default). Every
rule selects the server's series with job="<fullname>" and the release namespace, as
the chart's ServiceMonitor produces; set selector when Prometheus scrapes the server
another way. The chart README lists every alert and its default.
Migrations and database maintenance
By default the replicas maintain the schema themselves: set config. and they migrate on start. The runner takes a session-level advisory
lock, so replicas run migrations one after another instead of racing. Every replica also
creates and drops audit partitions. All of this needs the role that owns the tables, so
the server's credential can also rewrite the audit log.
appendOnlyAudit moves that work and that credential out of the server's
pods:
appendOnlyAudit:
enabled: true
runtimeRole: nauthera_runtime # create it once, before the first install
secrets:
databaseDsn: # the server, logging in as nauthera_runtime
existingSecret: nauthera-db-runtime
existingSecretKey: uri
ownerDatabaseDsn: # the maintenance Job and CronJob: the owning role
existingSecret: nauthera-db-owner
existingSecretKey: uriA pre-install,pre-upgrade hook Job runs nauthera-server maintain before every install
and upgrade, and a CronJob runs it on appendOnlyAudit. (daily at
03:17 UTC by default). Only those pods hold ownerDatabaseDsn. Because maintain
validates the server's configuration as the server does, they also get the MFA key (when
config.mfa.provider is file), the relay password and a hosted captcha's secret when
those are given, and nothing else. Each run grants the
runtime role read and write on every table but the audit log, and only SELECT and
INSERT on that. Remove config. from the production profile when
you turn it on; the chart refuses both together. Never give the runtime role table
privileges schema-wide or through ALTER DEFAULT PRIVILEGES: that would reach the audit
partitions. The chart README has the full setup, including CloudNativePG.
Helm waits for the hook Job within --timeout. A release whose migration adds an audit
index takes much longer on a large log; raise --timeout and
appendOnlyAudit. together for it. See
Backups & Upgrades.
The gRPC AdminService
The production profile turns the gRPC listener off
(config.), because nothing in it calls the AdminService.
To use it, set config. and certManager..
The chart issues the gRPC server and client certificates from a CA of the control
plane's own, even when certManager.issuerRef names an ACME issuer, because mTLS needs
ca.crt in the issued Secret and an ACME issuer does not fill it in.
certManager. lists who may call the AdminService and what each
may call. An identity is typed by the client-certificate SAN it matches, dns:<name> or
uri:<uri>; the common name never matches. Operations are organizations, clients,
brandings, identity_providers, ldap_directories (directory connections and their
bind passwords), broker_memberships (either side of a broker membership) and
email_domains (an organization's claimed email domains). Each entry also says where:
organizations lists the slugs it may act in (default is the main one) and
namespaces the Kubernetes namespaces its resources may name, * for every one. An
entry that does not say where refuses startup. The default admits the chart's own client
certificate in the main organization, from every namespace:
certManager:
grpcTLS:
enabled: true
allowedClients:
- identity: dns:nauthera-operator
operations: [clients, brandings, identity_providers, ldap_directories]
organizations: [default]
namespaces: ["*"]Add a tenant's slug for the operator to write into it, and organizations for it to
create and manage tenants from Organization resources.
The chart refuses to render gRPC without either mTLS or an explicit allow_insecure. The
server honours allow_insecure only on a loopback address, so the chart then binds the
listener to 127.0.0.1 inside the pod, reachable only through kubectl port-forward, as
in the kind profile.
nauthera-cli presents a client certificate when given --tls-ca-file,
--tls-cert-file and --tls-key-file; without them it connects without TLS, which only
an allow_insecure listener answers.
The operator
operator.enabled: true deploys the Kubernetes operator from the
same release, with its permissions and the OIDCClient, Organization, IdentityProvider, Branding, LDAPDirectory and BrokerMembership resource
definitions (see
Organization and
IdentityProvider). It needs certManager. and, by default, uses the client
certificate the chart issues:
helm upgrade --install nauthera deploy/helm/nauthera-server \
-f deploy/helm/nauthera-server/profiles/production.yaml \
--set ingress.host=auth.example.com \
--set config.server.grpc.enabled=true \
--set certManager.grpcTLS.enabled=true \
--set operator.enabled=true \
--set operator.networkPolicy.enabled=true \
--set 'operator.networkPolicy.apiServer.cidrs={10.96.0.1/32}'profiles/operator.yaml is the kind profile with the operator on, and
profiles/ the same with a CiliumNetworkPolicy. See
Operator for its values.
Exposure
The primary Service is always ClusterIP. If you set service.type to anything else,
the chart adds a second Service that carries only the public HTTP port. That keeps
the admin listener and the AdminService (which can create clients and mint their
secrets) off the network. A headless Service carries the gRPC control plane.
Without Helm
Plain manifests live in deploy/k8s/: a Namespace, the ConfigMaps, the Deployment, the
Services, a NetworkPolicy, a PodDisruptionBudget, a ServiceMonitor, a PrometheusRule, an
example Secret and migration-job.yaml, a one-shot Job that runs
nauthera-server maintain before a rollout. They run the AdminService with mTLS, from a
Secret nauthera-server-grpc-tls holding tls.crt, tls.key and ca.crt that you
issue yourself.
The image is FROM scratch, runs as non-root (UID 65532), and is built for
linux/amd64 and linux/arm64. Wire probes to the admin port (/healthz, /readyz,
/startupz), as the chart does. grpc.health.v1 shares the AdminService listener on
:9091, which takes mTLS (or, with allow_insecure, listens on loopback only), so the
kubelet's native gRPC probe cannot reach it. Set GOMEMLIMIT from the container's memory
limit.