Deploying Keydra
What Keydra needs, which of the three published images to run, and what to configure before it is reachable by anybody other than you.
Requirements#
| Component | Requirement |
|---|---|
Java |
21, for a source build. The published image carries its own runtime. |
PostgreSQL |
Required. Keydra keeps its own data here and there is no alternative — the application is non-blocking end to end and uses a reactive PostgreSQL driver. |
Container engine |
Podman or Docker, to run the image. The repository is built with Podman and its manifests are Kubernetes Pod manifests rather than Compose files. |
A target |
At least one, reachable from wherever Keydra runs. Redis, Valkey or a compatible fork, an Aerospike or a TiKV. |
Redis for Keydra’s own store |
Optional. Needed only when more than one instance runs against one database. Never one of your targets. |
ClickHouse |
Optional. Only if readings should outlive a restart. |
An SMTP relay |
Optional. Only if invitations and password resets should arrive by mail. |
Node 24.19.0 and yarn are needed only to build the frontend from source. A deployment that runs the published image needs neither.
The published images#
Keydra is published at quay.io/keydrahq as three images. Which one you want depends
on whether the interface is served by Keydra or by something else.
| Image | What it is |
|---|---|
|
The interface built into the API’s own static resources, so one container serves both. This is the ordinary deployment and the one the manifests in the repository use. |
|
The API alone. For a deployment that serves the interface separately — from a CDN, from an existing web server, or as its own pod. |
|
The interface, and the proxy it needs. The image carries nginx: it serves the static files
and routes |
The interface calls /api/v1 and /graphql as relative paths. There is no setting that
points it at an API somewhere else, deliberately: an absolute API address in a static bundle
is a build-time decision about a run-time fact, and it is also a cross-origin cookie problem.
So the interface and the API have to answer on one address. quay.io/keydrahq/keydra-ui is built to be that
address: it serves the interface at / and forwards /api/v1 and /graphql — and the
WebSocket upgrades on them — to KEYDRA_BACKEND. The session cookie stays a first-party
cookie, which is what makes SameSite=Lax work.
Put your own proxy in front of it if you already have one, but do not put one between the two: the routing is already done.
If you have no reason to run them apart, use quay.io/keydrahq/keydra.
The examples here use latest. A deployment should pin a version instead, so a restart
does not become an upgrade nobody scheduled.
Deploying Keydra#
-
Podman, or another engine that can run an OCI image.
-
A PostgreSQL Keydra can reach, or the one in the manifest below.
-
Create the secrets.
KEYDRA_SECRET_KEYencrypts every stored credential:printf 'keydra' | podman secret create keydra-db-password - openssl rand -base64 32 | podman secret create keydra-secret-key -Important:ImportantLosing
KEYDRA_SECRET_KEYmeans losing every stored credential. Sharing it means sharing them. Keep it wherever your other secrets live, not in the repository. -
Pull the image:
podman pull quay.io/keydrahq/keydra:latest -
Run it, together with a PostgreSQL:
podman play kube deploy/keydra-prod.yamlThe manifest names a locally built image. Point it at the published one:
- name: keydra image: quay.io/keydrahq/keydra:latest
curl -s http://localhost:8181/q/health/readyThen open http://localhost:8181.
Installing on Kubernetes with Helm#
The chart installs either shape: one image serving the API and the interface it calls, or the
two apart. It will not install what it cannot start, so two of the steps below are refusals
you would otherwise meet as a CrashLoopBackOff.
-
A Kubernetes cluster and Helm 3.
-
A PostgreSQL the cluster can reach. The chart does not install one — what is kept there is every connection profile, account, grant and audit row, and a database packaged into an application’s chart is a database nobody is backing up, running on a pod whose replacement is what an upgrade is.
-
Make the Secret. The chart will not generate the key, and that is deliberate: a generated key is regenerated on the next upgrade, and the instance comes back unable to read a single stored credential — reported one target at a time rather than as a failure to start.
kubectl create secret generic keydra \ --from-literal=secret-key="$(openssl rand -base64 32)" \ --from-literal=database-password='...'Important:ImportantLosing that key means losing every stored credential. Sharing it means sharing them.
-
Add the chart repository:
helm repo add keydra https://keydrahq.github.io/keydra-helm helm repo updateThe same package is also published as an OCI artifact, for a cluster that pulls everything from one registry:
helm install keydra oci://quay.io/keydrahq/charts/keydra --version <version>. -
Install:
helm install keydra keydra/keydra \ --set database.url=postgresql://postgres:5432/keydra \ --set existingSecret.name=keydra -
Tell it that it is behind a proxy, which under an Ingress it always is:
helm upgrade keydra keydra/keydra --reuse-values \ --set proxy.enabled=true \ --set proxy.trusted=10.0.0.0/8 \ --set publicUrl=https://keydra.example.com \ --set ingress.enabled=true \ --set ingress.hosts[0].host=keydra.example.comTold nothing, Keydra sees every sign-in as coming from the ingress controller: the checks that compare a sign-in with the ones before it then compare everybody with everybody, and the limit on attempts counts the whole cluster as one network. Naming the proxies is not optional — with the switch on and nobody named, any client can claim any address, and the chart refuses to render.
-
helm test keydrastarts a pod that asks the release what only a running installation can answer: that it is ready, that the interface is served, and that/q/metricsis 404 on the address people are given. -
There are no accounts yet. Create the first administrator once, through an endpoint that stops working the moment there is one:
curl -fsS -X POST https://keydra.example.com/api/v1/auth/setup \ -H 'Content-Type: application/json' \ -d '{"username":"you","password":"..."}'
mode: standalone is the default and is one image. mode: split deploys quay.io/keydrahq/keydra-backend
and quay.io/keydrahq/keydra-ui as two Deployments behind two Services, which is worth the second one when
the two scale differently — several API replicas behind one set of static files — or when the
interface belongs somewhere the API does not.
helm install keydra keydra/keydra --set mode=split ...You do not name the images. The chart picks them from the mode, because a default that is right for one shape is wrong for the other and quietly pulling the all-in-one into a split deployment would look like it worked.
Installing on Kubernetes with the operator#
The operator installs the same objects the chart does, from a resource the cluster owns instead of from a release Helm keeps. For installing Keydra the two do the same thing; the operator is the answer when you want the cluster to keep it that way afterwards, or when you want a target to be a resource rather than something typed into a form.
-
A Kubernetes cluster, or an OpenShift.
-
A PostgreSQL the cluster can reach. The operator does not install one, for the reason the chart does not: what is kept there is every connection profile, account, grant and audit row, and a database an application brings up beside itself is a database nobody is backing up.
-
Install the operator. On OpenShift, find Keydra in OperatorHub and install it from there; nothing below this step is needed. Elsewhere:
kubectl create namespace keydra-operator kubectl apply -f https://github.com/keydrahq/keydra-operator/releases/latest/download/crds.yaml kubectl apply -f https://github.com/keydrahq/keydra-operator/releases/latest/download/operator.yamlTwo files rather than one, because they are applied by different people at different times: the custom resource definitions are cluster-wide and go in once, and the manager is a Deployment somebody upgrades.
-
Make the Secret, in the namespace the installation will live in. The operator will not write it, and the difference from the chart is deliberate: a key put in a custom resource is readable by anybody who can
get keydrain that namespace — a wider audience than anybody who can read Secrets, and the one least likely to have been thought about.kubectl create secret generic keydra \ --from-literal=secret-key="$(openssl rand -base64 32)" \ --from-literal=database-password='...'Important:ImportantLosing that key means losing every stored credential. Sharing it means sharing them.
-
Describe the installation and apply it:
apiVersion: keydra.io/v1alpha1 kind: Keydra metadata: name: keydra spec: database: url: postgresql://keydra-db:5432/keydra secret: name: keydra route: # <1> enabled: true proxy: # <2> enabled: true trusted: 10.0.0.0/81 On OpenShift. Elsewhere use ingress.enabledwithingress.hosts, and setpublicUrlto the address a browser will reach it at — a redirect URI is agreed with an identity provider in advance and has to match to the character.2 Under an Ingress or a Route there is always something in front. Told nothing, Keydra sees every sign-in as coming from the ingress controller: the checks that compare a sign-in with the ones before it then compare everybody with everybody, and the limit on attempts counts the whole cluster as one network. Naming the proxies is not optional — the API server refuses a resource that turns the switch on and names nobody.
-
The resource says whether it worked, so nothing else has to be read to find out:
$ kubectl get keydra NAME READY REPLICAS URL AGE keydra True 1 https://keydra.apps.example.com 2mREADYisFalsewith a reason inkubectl describe keydra keydrawhile anything is wrong. The one to expect first is aDegradedcondition naming a key missing from the Secret. -
There are no accounts yet. Create the first administrator once, through an endpoint that stops working the moment there is one:
curl -fsS -X POST https://keydra.apps.example.com/api/v1/auth/setup \ -H 'Content-Type: application/json' \ -d '{"username":"you","password":"..."}'
This is the part a chart cannot do. A store that something else in the cluster created can be handed to Keydra by the same manifest that created it, and taken away by the same deletion — a profile that exists because a resource says so also stops existing when the resource does.
It needs an account for the operator to sign in as, because Keydra has no credential for a machine — no token, no service account — so the operator signs in the way a browser does and keeps the session. Give it an account of its own rather than a person’s, so the audit log says which changes were somebody typing and which were a resource being applied. Writing a connection profile is an administrator’s permission, so the account has to hold that role.
kubectl create secret generic keydra-api \
--from-literal=api-username=operator \
--from-literal=api-password='...'Name it on the installation, and then declare targets:
apiVersion: keydra.io/v1alpha1
kind: Keydra
metadata:
name: keydra
spec:
apiAccount:
secretName: keydra-api
# …
---
apiVersion: keydra.io/v1alpha1
kind: KeydraConnection
metadata:
name: orders-cache
spec:
keydraRef: keydra
host: orders-redis
port: 6379
guarded: true
passwordSecret:
name: orders-redis
key: passwordkeydraRef names an installation in the same namespace, and cross-namespace is deliberately
not possible: it would let anybody who can create a resource in their own namespace add a
target to somebody else’s console.
The operator will not adopt a profile it did not create. If a target of that name already exists in Keydra — because somebody added it through the interface — the resource is refused rather than taking ownership of it and then deleting it when the resource goes.
Converting an existing profile is therefore deliberate rather than tidying, and it costs something: deleting and recreating it changes the profile’s id, and both a grant whose scope is that connection and membership of a server group are held against that id. The people who could see that target will stop being able to.
The two produce the same objects with the same names and the same labels, which makes moving between them an adoption rather than a delete and a recreate. The steps are in the operator’s own documentation, because the parts that matter are about Helm rather than about Keydra: the release has to be told to leave its objects behind, and a Secret the chart rendered has to be copied into one the operator can be pointed at.
Configuring authentication before exposing Keydra#
Enforcement is on by default. deploy/keydra-prod.yaml includes it turned off so the
manifest runs unchanged on a machine only you can reach — and the interface says
"security off" on every page, because an open instance that looks secured is how one ends
up exposed.
-
Decide how people will sign in. Either is enough on its own:
-
Local accounts. Nothing to configure. Create the first administrator on first run and invite the rest.
-
An identity provider. Configured while running, from the interface, rather than through environment variables — see Identity providers.
-
-
Turn enforcement on by removing
KEYDRA_SECURITY_ENABLEDfrom the manifest, or setting it totrue. -
Tell Keydra its own public address, so a provider’s redirect comes back to the right place:
KEYDRA_PUBLIC_URL=https://keydra.example.com -
If Keydra runs behind a reverse proxy, say so, and name the proxies it should believe about the client’s address:
KEYDRA_BEHIND_PROXY=true KEYDRA_TRUSTED_PROXIES=10.0.0.0/8 -
Serve it over HTTPS. The session cookie is marked
Securein production, and a session cookie sent over plain HTTP is a session cookie on the wire.
Open Keydra in a private window. It offers a sign-in form or a provider button rather than the interface, and no page carries the "security off" notice.
KEYDRA_SECURITY_ENABLED=false admits everybody who can reach the address, with every
permission. Use it for a demonstration or a machine only you can reach, and never on
anything exposed.
Everything a deployment can set#
Every environment variable Keydra reads, grouped by the decision it belongs to, with the value a packaged runtime starts from. Anything with no default and no value is either off or absent.
Two groups need reading before the others. Required settings, because Keydra does not start without them. Enforcement, cookies and sessions, because their defaults are safe and turning one off is a decision rather than a tweak.
| Environment variable | Default | Description |
|---|---|---|
|
|
The reactive PostgreSQL URL, as |
|
|
The database user. |
|
The database password. |
|
|
The key that encrypts every stored credential — target passwords, tunnel keys, provider secrets, destination credentials. 32 random bytes, base64. There is no default: without it, nothing that was stored can be read. |
| Environment variable | Default | Description |
|---|---|---|
|
Where Keydra is, as a browser sees it. A provider’s redirect and the links in outgoing mail are built from this, so it has to be the address people actually use rather than the one the process binds to. |
|
|
|
Whether to believe the |
|
Which proxies may set those headers, as addresses or CIDR ranges. Without it, believing them means believing anybody. |
|
|
|
The content security policy the browser is told to enforce. A property rather than a literal, so a deployment serving assets from elsewhere can widen the one directive it needs instead of turning the header off. Widening one means restating them all, which is the point: a policy is a whole or it is nothing. |
|
|
The largest request body accepted. This is what bounds a key import and a backup restore. |
|
|
Whether every request is logged. A request line carries a path, and a path here can name a key. |
| Environment variable | Default | Description |
|---|---|---|
|
|
Whether Keydra enforces who may do what. Turning it off admits everybody who can reach the address, with every permission, and every page then says so — an open instance that looks secured is how one ends up exposed. |
|
|
Whether the session cookie is marked Secure. On in production: a session cookie sent over plain HTTP is a session cookie on the wire. |
|
|
Whether a WebSocket handshake must come from an origin Keydra recognizes. A socket is not covered by the same-origin policy the way a fetch is, so this is the check that replaces it. |
|
The origins a WebSocket may be opened from, beyond the public URL. For a deployment where the interface is served from somewhere else. |
|
|
|
Whether repeated sign-in failures are counted and refused. The limit is answered before the password hash, because Argon2id is slow on purpose and unlimited attempts consume the server’s memory as well as guessing a password. |
|
A GeoIP database, if one is available. Used to say where a sign-in came from when comparing it with the ones before it. |
|
|
|
How often expired session rows are deleted. A session table nobody prunes is a table that grows for as long as the application runs. |
|
|
How long an invitation or password-reset link works. A link that works forever is a password with extra steps. |
|
Keys that may still be read but are no longer written. This is what makes a key rotation something other than an outage: the new key writes, the old ones keep decrypting what they wrote, and the re-encryption moves everything across while the instance is up. |
| Environment variable | Default | Description |
|---|---|---|
|
|
Whether an address somebody typed may be on a private network. Usually yes: a webhook to an internal chat relay and an S3-compatible store on the same subnet are the ordinary cases. Link-local is refused regardless and has no setting. |
|
|
Whether an address somebody typed may point back at the machine Keydra runs on. Off outside development. |
|
Hosts that are allowed regardless of the rules above. An escape hatch for the one internal address a deployment has to reach. |
| Environment variable | Default | Description |
|---|---|---|
|
The issuer of a single OIDC provider configured at boot. Providers added in the interface are the supported path and need none of these four; this is for a deployment that configures one before there is anybody to sign in and add it. |
|
|
|
The client id at that provider. |
|
The client secret at that provider. |
|
|
|
Where the roles are in the token, as a path. A claim of |
| Environment variable | Default | Description |
|---|---|---|
|
What this instance calls itself, in the About page, in every log line and on every metric it produces. Generated when it is not set. |
|
|
|
Keydra’s own Redis, for the shared cache and for re-broadcasting notifications between instances. Never one of your targets: a cache living in a server somebody is browsing is a cache somebody empties with a bulk delete. Leave it unset for a single instance. |
|
|
How long the leader lease is held before it has to be renewed. An instance that stops renewing loses it to whoever asks next, so this is also how long the work can be interrupted by a crash. |
|
|
How often an instance checks whether it should be doing the leader work — claiming the lease if nobody holds it, and letting go of the schedules if it has lost it. |
| Environment variable | Default | Description |
|---|---|---|
|
The SMTP relay outgoing mail goes through — invitations, password resets, and email alert deliveries. |
|
|
|
The relay port. |
|
|
Whether to use TLS to the relay. |
|
The account Keydra authenticates to the relay as. |
|
|
The relay password or API key. |
|
|
The address outgoing mail is sent from. Most relays refuse a message without one. |
| Environment variable | Default | Description |
|---|---|---|
|
|
The directory a local backup destination writes inside. A destination names a directory relative to this, so no destination can be pointed at an arbitrary path on the machine. |
|
|
Whether readings are also written somewhere that survives a restart. Off by default: another service in a deployment is a real cost, and an instance that does not want one must not be told it needs one. |
|
The ClickHouse HTTP interface. HTTP rather than the JDBC driver, which is blocking — and this application is not. |
|
|
|
The ClickHouse user. |
|
The ClickHouse password. |
| Environment variable | Default | Description |
|---|---|---|
|
Where OpenTelemetry traces are exported. Setting it is what turns tracing on; there is no second flag. |
|
|
|
JSON on the console instead of the human-readable format, so a log shipper does not have to parse a layout written for a person. |
The same settings are listed alphabetically, with the property each one sets, in the configuration reference. This page groups them by what you are deciding; that one answers "what is this".
A complete example#
Every group at once, so nothing has to be guessed at. Most deployments set a fraction of it — the four under Required, an address, and whatever they actually run.
env:
# --- Required -------------------------------------------------------------
- name: KEYDRA_DB_URL
value: postgresql://db.internal:5432/keydra
- name: KEYDRA_DB_USERNAME
value: keydra
- name: KEYDRA_DB_PASSWORD
valueFrom: { secretKeyRef: { name: keydra-db-password, key: keydra-db-password } }
# 32 random bytes, base64. Losing it loses every stored credential.
- name: KEYDRA_SECRET_KEY
valueFrom: { secretKeyRef: { name: keydra-secret-key, key: keydra-secret-key } }
# --- Address and reverse proxy --------------------------------------------
- name: KEYDRA_PUBLIC_URL
value: https://keydra.example.com
- name: KEYDRA_BEHIND_PROXY
value: "true"
- name: KEYDRA_TRUSTED_PROXIES
value: 10.0.0.0/8
# Raise only if a key import or a backup restore is larger than this.
- name: KEYDRA_MAX_BODY_SIZE
value: 25M
# --- Enforcement ----------------------------------------------------------
# On by default. Off admits everybody who can reach the address.
- name: KEYDRA_SECURITY_ENABLED
value: "true"
- name: KEYDRA_COOKIE_SECURE
value: "true"
# --- More than one instance ----------------------------------------------
- name: KEYDRA_INSTANCE_ID
value: keydra-a
# Keydra's own Redis. Never one of your targets.
- name: KEYDRA_STORE_URL
value: redis://store.internal:6379
# --- Outgoing mail --------------------------------------------------------
- name: KEYDRA_MAIL_HOST
value: smtp.example.com
- name: KEYDRA_MAIL_PORT
value: "587"
- name: KEYDRA_MAIL_TLS
value: "true"
- name: KEYDRA_MAIL_USERNAME
value: keydra
- name: KEYDRA_MAIL_API_KEY
valueFrom: { secretKeyRef: { name: keydra-mail-key, key: keydra-mail-key } }
- name: KEYDRA_MAIL_FROM
value: keydra@example.com
# --- Metrics storage --------------------------------------------------------
- name: KEYDRA_CLICKHOUSE_ENABLED
value: "true"
- name: KEYDRA_CLICKHOUSE_URL
value: http://clickhouse.internal:8123
- name: KEYDRA_CLICKHOUSE_USER
value: keydra
- name: KEYDRA_CLICKHOUSE_PASSWORD
valueFrom: { secretKeyRef: { name: keydra-clickhouse, key: keydra-clickhouse } }
# --- Observability --------------------------------------------------------
- name: KEYDRA_OTLP_ENDPOINT
value: http://otel-collector.internal:4317
- name: KEYDRA_JSON_LOGS
value: "true"The four valueFrom entries above are not decoration. A password written into a manifest
is a password in the repository, in the deployment history and in whatever prints the
manifest — and KEYDRA_SECRET_KEY in particular is the one value that unlocks every other
credential Keydra holds.
Settings this page does not list#
Keydra is a Quarkus application, so every Quarkus setting is available even when
it has no KEYDRA_-prefixed name of its own. The mapping is Quarkus' own: upper-case the
property and replace each non-alphanumeric character with an underscore, so
quarkus.http.port is QUARKUS_HTTP_PORT.
Confirm that mapping against your Quarkus version before relying on it for a property with unusual characters in its name.
Ports and endpoints#
| Address | Port | What it is |
|---|---|---|
|
8181 |
The interface and everything below. One port serves the API and the page that calls it. |
|
8181 |
The REST API. |
|
8181 |
The GraphQL surface. |
|
8181 |
Health. The manifests use |
|
8181 |
Prometheus. |
|
8181 |
The OpenAPI document. |
|
8181 |
Interactive API browsing. Development profile only. |
| What | Host port | Notes |
|---|---|---|
Backend |
8181 |
|
Frontend dev server |
9000 |
|
Redis target |
6479 |
From |
Valkey target |
6480 |
From |
Redis for Keydra’s store |
6481 |
Never one of the targets |
PostgreSQL |
5442 |
Keydra’s own database |
ClickHouse |
8223 |
Optional readings store |
The host ports are shifted off the defaults deliberately, so the pod starts on a machine that already runs a Redis or a PostgreSQL. Inside the pod they keep their canonical numbers.
Building the image from source#
Only if you are changing Keydra. A deployment should use the published images.
-
A checkout of the Keydra repository.
-
Podman.
podman build -t localhost/keydra:dev -f Containerfile .The Containerfile in the repository builds the standalone image in three stages: the
frontend with Node, the backend with Maven — with the built frontend copied into
src/main/resources/META-INF/resources — and a JRE image that carries neither toolchain.
podman run --rm -p 8181:8181 \
-e KEYDRA_DB_URL=postgresql://host.containers.internal:5442/keydra \
-e KEYDRA_DB_USERNAME=keydra \
-e KEYDRA_DB_PASSWORD=keydra \
-e KEYDRA_SECRET_KEY="$(openssl rand -base64 32)" \
localhost/keydra:devIt declares USER keydra and reads the container’s own memory limit rather than the host’s,
so a limited container does not size its heap for a machine it cannot use. Tests are not run
during the image build — they need containers, which a build container does not have; CI runs
them.