Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

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/helm/nauthera-server 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.io/nauthera/nauthera-server, the operator image ghcr.io/nauthera/nauthera-operator, and the chart at oci://ghcr.io/nauthera/charts/nauthera-server, 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.

Requirements

  • Kubernetes 1.27 or newer
  • Helm 3 or 4
  • cert-manager, when certManager.enabled is on: it is off by default, and every profile but kind-append-only.yaml turns it on
  • The Prometheus Operator CRDs, if you enable metrics.serviceMonitor or metrics.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.yaml

Pin --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.image.repository 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.com

The same check works for ghcr.io/nauthera/nauthera-server:$VERSION and ghcr.io/nauthera/nauthera-operator:$VERSION, 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/helm/nauthera-server 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 checkpoints

kind-up applies deploy/kind/postgres.yaml, 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-keys

The 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.yaml

The production profile also sets config.audit.checkpoint_key_required: true, 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 one

The 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.security.trusted_proxies 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.backendTLS.caConfigMap (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/kind-gateway.yaml 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.svg

See 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_limit.backend: redis without a cache, the gRPC listener without either mTLS or allow_insecure, in-pod HTTPS behind a route with no httpRoute.backendTLS.caConfigMap, a disabled admin listener or an overridden listener address, and, with config.audit.checkpoint_key_required, 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

  • config mirrors 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 secrets hold the server's secret material: databaseDsn, databasePassword, cachePassword, cacheSentinelPassword, mfaEncryptionKey, keysEncryptionKey, smtpPassword, captchaSecret (a hosted captcha provider's secret key), auditCheckpointKey, mfaRetiredKeys and keysRetiredKeys. A twelfth, ownerDatabaseDsn, is used only by the database-maintenance Job and CronJob (below). Each takes either an inline value (development only) or an existingSecret plus existingSecretKey.
  • secrets.delivery: file (the default) mounts eight of them as files (0440) and sets NAUTHERA_SERVER_<KEY>_FILE. auditCheckpointKey is 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 through secretKeyRef, because the server cannot read them from a file. env uses one explicit secretKeyRef per variable.

Certificates

CertificatePurposeIssued by
CASelf-signed CA in the release namespaceitself
Signing key (EC P-256)JWT signing key (ES256)the chart's CA
Signing key (RSA)RS256 key, which OIDC Discovery requires to be advertisedthe chart's CA
Audit checkpoint key (Ed25519)Signs the audit log's chain checkpointsthe chart's CA
IngressBrowser-facing certificatecertManager.issuerRef, or the chart's CA
HTTP (optional)In-pod HTTPSthe chart's CA
gRPC server and client (optional)mTLS for the AdminServicea 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.grpcTLS.client.duration); 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.audit.checkpoint_key_path for a key you mount yourself, secrets.auditCheckpointKey (a PEM PKCS#8 Ed25519 key, openssl genpkey -algorithm ed25519), or, with certManager.enabled, the key cert-manager issues (certManager.auditCheckpointKey, 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.

Email

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 password

The 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.allowPlaintextAuth: true, 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.prometheusRule.enabled renders the server's alerting rules as a PrometheusRule. They come in groups that can be switched off one by one under metrics.prometheusRule.rules: 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.metrics.serviceMonitor.enabled or metrics.prometheusRule.operatorSelector. 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.database.migrate: true 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: uri

A pre-install,pre-upgrade hook Job runs nauthera-server maintain before every install and upgrade, and a CronJob runs it on appendOnlyAudit.maintenance.schedule (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.database.migrate: true 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.maintenance.activeDeadlineSeconds together for it. See Backups & Upgrades.

The gRPC AdminService

The production profile turns the gRPC listener off (config.server.grpc.enabled: false), because nothing in it calls the AdminService. To use it, set config.server.grpc.enabled: true and certManager.grpcTLS.enabled: true. 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.grpcTLS.allowedClients 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.grpcTLS.enabled 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/operator-cilium.yaml 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.