# High availability

Run two or more replicas of helix-iam-server behind a load balancer. Every request may land on any replica: state
that a sign-in needs from one request to the next lives in PostgreSQL (or Redis, if you run it), never in one
replica's memory. The Helm chart's default is `replicaCount: 2`.

!!! info "Changed in 1.0.0-rc.13"
    Up to and including 1.0.0-rc.12, push approval, QR sign-in, transaction signing, device enrolment, CIBA, the SAML
    assertion replay cache and the rate limits were kept in each replica's memory: a flow that started on one pod
    could fail on another, and N replicas gave N rate-limit budgets. 1.0.0-rc.13 moved them into the
    shared store described below.

## What is shared, and where

| State | Where it lives |
| --- | --- |
| Browser sessions | `config.sessionStore` (`HELIX_SESSION_STORE`): `redis` (default) or `queue` (PostgreSQL) |
| Authorization codes, tokens, SSO sessions | `config.tokenStore` (`HELIX_TOKEN_STORE`): `queue` (PostgreSQL, default) or `redis` (the high-throughput tier) |
| Push approval, QR sign-in, transaction signing, device enrolment, CIBA, SAML assertion replay cache | the shared flow-state store: PostgreSQL table `flow_state`, or Redis keys `helix:flow:*` |
| Security rate limits (sign-in, MFA, registration, password reset, token endpoint, wallet, magic link, verification e-mail, account console) | the shared flow-state store: PostgreSQL table `rate_limit_bucket`, or Redis keys `helix:rl:*`; counted once for the whole cluster |
| EU Digital Identity Wallet transactions | PostgreSQL or Redis, see [the wallet guide](../federation/eudi-wallet.md#transaction-store-and-multiple-replicas) |
| Account lockout counters | PostgreSQL, one locked row per account |

The flow state and rate limits follow the stores: **Redis** when `config.sessionStore` is `redis` (or
`config.tokenStore` is `redis` and the session store is not `queue`), **PostgreSQL** otherwise.
`config.clusterStateStore` (`HELIX_CLUSTER_STATE_STORE`: `jdbc`, `redis` or `memory`) overrides that choice;
`memory` is for a single replica only.

Every transition of a flow (approve, deny, sign, confirm, consume, poll) is an atomic compare-and-set, so two
replicas racing on the same flow cannot both win. Flow ids are stored as SHA-256 hashes and the payload (challenges,
nonces, rotating QR tokens) is encrypted with `DB_ENCRYPTION`. Flyway migration V87 creates the two tables; expired
rows are swept.

## Running without Redis

Redis is used only by the two stores above. Put both on `queue`, leave `redis.host` empty, and everything (sessions,
tokens, flow state, rate limits, wallet transactions) lives in PostgreSQL:

```bash
helm install helixiam oci://ghcr.io/melmarzgioui/helixiam/charts/helixiam --version 1.1.0-rc.14 ... --set config.sessionStore=queue   # no redis.host
```

The chart then sets no `REDIS_HOST`, its NetworkPolicy opens no Redis port, and the server never contacts Redis (its
health indicator is off, so readiness does not depend on it). The release gate boots this configuration
on a fresh k3d cluster without any Redis. The
[municipality profile](municipality-hosting.md) runs this way, with `config.clusterStateStore: jdbc`.

If you do run Redis, configure it with `maxmemory-policy noeviction`: an evicting Redis could drop a drained
rate-limit bucket, or a session, under memory pressure. The server logs a WARN at startup when it can read another
policy.

## Warnings at startup

- The chart passes `replicaCount` to the server as `HELIX_CLUSTER_EXPECTED_REPLICAS`. A server that keeps shared
  state in memory while more than one replica is likely (that setting above 1, or a Kubernetes environment without
  it) logs a WARN.
- If PostgreSQL or Redis is briefly unreachable, a replica counts rate limits on its own rather than failing
  requests, and logs it.

## Still per replica, by design

These protect the pod's own resources and are not a security budget: the outbound fetch concurrency caps
(`helix.outbound.fetch.*`), the e-mail send caps, and the workload-identity token-exchange probe limiter (the token's
signature is the control).

## Chart values for availability

| Value | Purpose |
| --- | --- |
| `replicaCount` | number of server replicas (default `2`) |
| `strategy` | the Deployment's update strategy; RollingUpdate with `maxUnavailable: 0` keeps capacity during a roll |
| `topologySpread.enabled` | spread replicas over zones and nodes |
| `podDisruptionBudget.minAvailable` | replicas kept during voluntary disruptions such as node drains |

The [municipality profile](municipality-hosting.md) sets all four (three replicas, at least two available). For
the database itself, use a highly available PostgreSQL with point-in-time recovery; see
[Backup & disaster recovery](backup.md).

## Upgrading to a release with the shared store

Flows in progress during the upgrade (push, QR, transaction signing, enrolment, CIBA) are lost, as they were in
memory; users start them again. Rate-limit budgets start fresh. A custom `PushApprovalStore`, `QrSessionStore`,
`TransactionSigningStore` or `DeviceEnrollmentTicketStore` bean must implement the new `replace` method.

## See also

- [Install](../getting-started/install.md) and [Configuration](../getting-started/configuration.md)
- [Security hardening: rate limits](security.md#rate-limits)
- [Upgrades & migrations](upgrades.md)
