# Installation on Kubernetes with Helm

> Install nauthera-server on Kubernetes with its Helm chart: on a local kind cluster, then in the production profile with PostgreSQL, Redis and cert-manager.

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](https://www.nauthera.io/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:

```bash
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:

```bash
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:

```bash
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](https://www.nauthera.io/docs/v1/quick-start) runs the published chart on kind, with no
checkout. From a checkout of nauthera-server, the recipes build the image instead:

```bash
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:

```yaml
# 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](https://www.nauthera.io/docs/v1/operations#where-state-lives).

```bash
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](#certificates)).

## Gateway API

`httpRoute` publishes the public listener through a Gateway API `HTTPRoute`, instead of
the Ingress or beside it while you move over:

```yaml
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:

```yaml
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](https://www.nauthera.io/docs/v1/configuration#the-main-organization).

## 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:

```console
$ 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](https://www.nauthera.io/docs/v1/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](#migrations-and-database-maintenance)). 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

| 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.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](https://www.nauthera.io/docs/v1/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`:

```yaml
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`:

```yaml
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:

```yaml
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](https://www.nauthera.io/docs/v1/operations#schema-migrations).

## 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:

```yaml
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](https://www.nauthera.io/docs/v1/operator) from the
same release, with its permissions and the `OIDCClient`, `Organization`, `IdentityProvider`, `Branding`, `LDAPDirectory` and `BrokerMembership` resource
definitions (see
[Organization](https://www.nauthera.io/docs/v1/organization-resource) and
[IdentityProvider](https://www.nauthera.io/docs/v1/identity-provider-resource)). It needs `certManager.grpcTLS.enabled` and, by default, uses the client
certificate the chart issues:

```bash
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](https://www.nauthera.io/docs/v1/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.
