Skip to content

5.3.4 Identity & Access Management

Keycloak provides centralised authentication and authorisation for the RCIIS platform. It acts as the OIDC/SAML identity provider for the Kubernetes API, the APISIX Gateway, Grafana, and RCIIS application services — replacing scattered local credentials with a single, auditable identity layer.

How to use this page

Each component has an Install section showing the Flux manifests, a Configuration section with resource specs, and a Verify section to confirm it is working.

All code blocks are labelled with their file path in the repository. Select your target environment (AWS or Bare Metal) in any tab group — the choice syncs across the entire page.

  • Using the existing rciis-devops repository: All files already exist. Skip the mkdir and git add/git commit commands — they are for users building a new repository. Simply review the files, edit values for your environment, and push.
  • Building a new repository from scratch: Follow the mkdir, file creation, and git commands in order.
  • No Git access: Use kubectl apply instead of git push to deploy manifests directly.

Keycloak Operator

The RCIIS deployment uses the official Keycloak Operator (k8s.keycloak.org/v2alpha1) deployed via Flux as raw Kubernetes manifests. The operator manages the Keycloak StatefulSet, database integration, and realm lifecycle declaratively.

Directory Structure

Create the Flux directories for your environment:

mkdir -p flux/infra/aws/keycloak

The directory will contain:

flux/infra/aws/keycloak/
├── namespace.yaml
├── operator.yaml                 # Operator Deployment, RBAC, Service
├── pg-instance.yaml              # CloudNativePG PostgreSQL cluster
├── kc.yaml                       # Keycloak CR
├── httproutes.yaml               # Gateway API routes for auth.rciis.africa
├── kustomization.yaml            # Flux Kustomization references above
└── secrets/
    └── keycloak-admin.enc.yaml   # SOPS-encrypted admin credentials
mkdir -p flux/infra/baremetal/keycloak

The directory will contain:

flux/infra/baremetal/keycloak/
├── namespace.yaml
├── operator.yaml                 # Operator Deployment, RBAC, Service
├── pg-instance.yaml              # CloudNativePG PostgreSQL cluster
├── kc.yaml                       # Keycloak CR
├── httproutes.yaml               # Gateway API routes for auth.rciis.africa
├── kustomization.yaml            # Flux Kustomization references above
└── secrets/
    └── keycloak-admin.enc.yaml   # SOPS-encrypted admin credentials
mkdir -p flux/infra/baremetal/keycloak

The directory will contain:

flux/infra/baremetal/keycloak/
├── namespace.yaml
├── operator.yaml                 # Operator Deployment, RBAC, Service
├── pg-instance.yaml              # CloudNativePG PostgreSQL cluster
├── kc.yaml                       # Keycloak CR
├── httproutes.yaml               # Gateway API routes for auth.rciis.africa
├── kustomization.yaml            # Flux Kustomization references above
└── secrets/
    └── keycloak-admin.enc.yaml   # SOPS-encrypted admin credentials

Namespace

Save the following as namespace.yaml:

flux/infra/{aws,baremetal}/keycloak/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: keycloak

Operator Deployment

The operator is deployed as a Kubernetes Deployment with RBAC. Save the following as operator.yaml:

Operator Version

Pin the Keycloak operator to a specific release tag. Update image: to upgrade. Test upgrades in a non-production environment first.

flux/infra/aws/keycloak/operator.yaml
# Keycloak Operator ServiceAccount, RBAC, Deployment
apiVersion: v1
kind: ServiceAccount
metadata:
  name: keycloak-operator
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: keycloak-operator-clusterrole
  labels:
    app.kubernetes.io/name: keycloak-operator
rules:
  - apiGroups: [config.openshift.io]
    resources: [ingresses]
    verbs: [get]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: keycloakcontroller-cluster-role
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
rules:
  - apiGroups: [k8s.keycloak.org]
    resources: [keycloaks, keycloaks/status, keycloaks/finalizers]
    verbs: [get, list, watch, patch, update, create, delete]
  - apiGroups: [""]
    resources: [services]
    verbs: [create, delete, get, list, patch, update, watch]
  - apiGroups: [monitoring.coreos.com]
    resources: [servicemonitors]
    verbs: [create, delete, get, list, patch, update, watch]
  - apiGroups: [networking.k8s.io]
    resources: [networkpolicies]
    verbs: [create, delete, get, list, patch, update, watch]
  - apiGroups: [apps]
    resources: [statefulsets]
    verbs: [create, delete, get, list, patch, update, watch]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses]
    verbs: [create, delete, get, list, patch, update, watch]
  - apiGroups: [""]
    resources: [secrets]
    verbs: [create, delete, get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: keycloakrealmimportcontroller-cluster-role
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
rules:
  - apiGroups: [k8s.keycloak.org]
    resources: [keycloakrealmimports, keycloakrealmimports/status, keycloakrealmimports/finalizers]
    verbs: [get, list, watch, patch, update, create, delete]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [create, delete, get, list, patch, watch]
  - apiGroups: [""]
    resources: [secrets]
    verbs: [create, delete, get, list, patch, update, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: keycloak-operator-clusterrole-binding
  labels:
    app.kubernetes.io/name: keycloak-operator
roleRef:
  kind: ClusterRole
  apiGroup: rbac.authorization.k8s.io
  name: keycloak-operator-clusterrole
subjects:
  - kind: ServiceAccount
    name: keycloak-operator
    namespace: keycloak
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: keycloak-operator-role
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
rules:
  - apiGroups: [apps]
    resources: [statefulsets]
    verbs: [get, list, watch, create, delete, patch, update]
  - apiGroups: [""]
    resources: [configmaps]
    verbs: [get, list, watch]
  - apiGroups: [""]
    resources: [secrets, services]
    verbs: [get, list, watch, create, delete, patch, update]
  - apiGroups: [""]
    resources: [pods]
    verbs: [list]
  - apiGroups: [""]
    resources: [pods/log]
    verbs: [get]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [get, list, watch, create, delete, patch, update]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses]
    verbs: [get, list, watch, create, delete, patch, update]
  - apiGroups: [monitoring.coreos.com]
    resources: [servicemonitors]
    verbs: [create, delete, get, list, update, watch, patch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: keycloak-operator-role-binding
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
roleRef:
  kind: Role
  apiGroup: rbac.authorization.k8s.io
  name: keycloak-operator-role
subjects:
  - kind: ServiceAccount
    name: keycloak-operator
    namespace: keycloak
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: keycloakcontroller-role-binding
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
roleRef:
  kind: ClusterRole
  apiGroup: rbac.authorization.k8s.io
  name: keycloakcontroller-cluster-role
subjects:
  - kind: ServiceAccount
    name: keycloak-operator
    namespace: keycloak
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: keycloakrealmimportcontroller-role-binding
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
roleRef:
  kind: ClusterRole
  apiGroup: rbac.authorization.k8s.io
  name: keycloakrealmimportcontroller-cluster-role
subjects:
  - kind: ServiceAccount
    name: keycloak-operator
    namespace: keycloak
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: keycloak-operator-view
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
roleRef:
  kind: ClusterRole
  apiGroup: rbac.authorization.k8s.io
  name: view
subjects:
  - kind: ServiceAccount
    name: keycloak-operator
    namespace: keycloak
---
apiVersion: v1
kind: Service
metadata:
  name: keycloak-operator
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
spec:
  ports:
    - name: http
      port: 80
      protocol: TCP
      targetPort: 8080
  selector:
    app.kubernetes.io/name: keycloak-operator
  type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: keycloak-operator
  namespace: keycloak
  labels:
    app.kubernetes.io/name: keycloak-operator
    app.kubernetes.io/version: 26.5.3
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: keycloak-operator
  template:
    metadata:
      labels:
        app.kubernetes.io/name: keycloak-operator
        app.kubernetes.io/version: 26.5.3
    spec:
      serviceAccountName: keycloak-operator
      containers:
        - name: keycloak-operator
          image: quay.io/keycloak/keycloak-operator:26.5.3
          imagePullPolicy: Always
          env:
            - name: KUBERNETES_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: RELATED_IMAGE_KEYCLOAK
              value: quay.io/keycloak/keycloak:26.5.3
            - name: QUARKUS_OPERATOR_SDK_CONTROLLERS_KEYCLOAKREALMIMPORTCONTROLLER_NAMESPACES
              value: JOSDK_WATCH_CURRENT
            - name: QUARKUS_OPERATOR_SDK_CONTROLLERS_KEYCLOAKCONTROLLER_NAMESPACES
              value: JOSDK_WATCH_CURRENT
          ports:
            - containerPort: 8080
              name: http
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /q/health/live
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /q/health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: 300m
              memory: 450Mi
            limits:
              cpu: 700m
              memory: 450Mi

Same as AWS. The operator manifest is environment-agnostic.

Same as AWS. The operator manifest is environment-agnostic.

PostgreSQL Database (CloudNativePG)

Keycloak requires an external PostgreSQL database. This is provisioned using the CloudNativePG operator. Save the following as pg-instance.yaml:

flux/infra/aws/keycloak/pg-instance.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: keycloak-postgres
  namespace: keycloak
  annotations:
    cnpg.io/reload-secrets: "true"  # Enables live reload of secrets
spec:
  description: "PostgreSQL for Keycloak"
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:15.0

  managed:
    roles:
      - name: keycloak
        ensure: present
        comment: Keycloak User
        login: true
        superuser: false
        inRoles:
          - pg_monitor
          - pg_signal_backend
        passwordSecret:
          name: cnpg-keycloak-owner

  bootstrap:
    initdb:
      database: keycloak
      owner: keycloak
      secret:
        name: cnpg-keycloak-owner

  postgresql:
    pg_hba:
      - host keycloak keycloak 0.0.0.0/0 scram-sha-256

  superuserSecret:
    name: cnpg-keycloak-superuser

  storage:
    storageClass: gp3
    size: 10Gi

  resources:
    requests:
      memory: "64Mi"
      cpu: "10m"
    limits:
      memory: "1Gi"
      cpu: "500m"

Same as AWS, but adjust storageClass to match your environment (e.g., ceph-rbd-single):

flux/infra/baremetal/keycloak/pg-instance.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: keycloak-postgres
  namespace: keycloak
  annotations:
    cnpg.io/reload-secrets: "true"
spec:
  description: "PostgreSQL for Keycloak"
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:15.0

  managed:
    roles:
      - name: keycloak
        ensure: present
        comment: Keycloak User
        login: true
        superuser: false
        inRoles:
          - pg_monitor
          - pg_signal_backend
        passwordSecret:
          name: cnpg-keycloak-owner

  bootstrap:
    initdb:
      database: keycloak
      owner: keycloak
      secret:
        name: cnpg-keycloak-owner

  postgresql:
    pg_hba:
      - host keycloak keycloak 0.0.0.0/0 scram-sha-256

  superuserSecret:
    name: cnpg-keycloak-superuser

  storage:
    storageClass: ceph-rbd-single
    size: 10Gi

  resources:
    requests:
      memory: "64Mi"
      cpu: "10m"
    limits:
      memory: "1Gi"
      cpu: "500m"

Same as AWS, but adjust storageClass to match your environment (e.g., ceph-rbd-single):

flux/infra/baremetal/keycloak/pg-instance.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: keycloak-postgres
  namespace: keycloak
  annotations:
    cnpg.io/reload-secrets: "true"
spec:
  description: "PostgreSQL for Keycloak"
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:15.0

  managed:
    roles:
      - name: keycloak
        ensure: present
        comment: Keycloak User
        login: true
        superuser: false
        inRoles:
          - pg_monitor
          - pg_signal_backend
        passwordSecret:
          name: cnpg-keycloak-owner

  bootstrap:
    initdb:
      database: keycloak
      owner: keycloak
      secret:
        name: cnpg-keycloak-owner

  postgresql:
    pg_hba:
      - host keycloak keycloak 0.0.0.0/0 scram-sha-256

  superuserSecret:
    name: cnpg-keycloak-superuser

  storage:
    storageClass: ceph-rbd-single
    size: 10Gi

  resources:
    requests:
      memory: "64Mi"
      cpu: "10m"
    limits:
      memory: "1Gi"
      cpu: "500m"

Keycloak CR

The Keycloak custom resource declares the desired Keycloak instance. The operator watches this CR and reconciles the StatefulSet, services, and other resources. Save the following as kc.yaml:

flux/infra/aws/keycloak/kc.yaml
apiVersion: k8s.keycloak.org/v2alpha1
kind: Keycloak
metadata:
  name: kc
  namespace: keycloak
spec:
  instances: 1

  db:
    vendor: postgres
    host: keycloak-postgres-rw
    usernameSecret:
      name: cnpg-keycloak-owner
      key: username
    passwordSecret:
      name: cnpg-keycloak-owner
      key: password

  http:
    httpEnabled: true

  hostname:
    hostname: https://auth.rciis.africa
    admin: https://keycloak.rciis.africa
    strict: true

  ingress:
    enabled: false

  proxy:
    headers: xforwarded

  bootstrapAdmin:
    service:
      secret: kc-crossplane-service

Same as AWS. Replace .rciis.africa with your bare metal domain if different.

Same as AWS. Replace .rciis.africa with your bare metal domain if different.

Gateway API Routes

Keycloak is exposed via Gateway API HTTPRoute resources. Two routes are created: one for public users (auth.rciis.africa) and one for administrators (keycloak.rciis.africa). Save the following as httproutes.yaml:

flux/infra/aws/keycloak/httproutes.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: keycloak-auth
  namespace: keycloak
spec:
  parentRefs:
    - name: aws-gateway
      namespace: kube-system
      sectionName: https-auth
  hostnames:
    - auth.rciis.africa
  rules:
    - backendRefs:
        - name: kc-service
          port: 8080
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: keycloak-admin
  namespace: keycloak
spec:
  parentRefs:
    - name: aws-gateway
      namespace: kube-system
      sectionName: https-keycloak
  hostnames:
    - keycloak.rciis.africa
  rules:
    - backendRefs:
        - name: kc-service
          port: 8080

Same as AWS. Update the parentRef sectionName if your bare metal gateway uses different listener names.

Same as AWS. Update the parentRef sectionName if your bare metal gateway uses different listener names.

Kustomization

Create a kustomization.yaml to tell Flux which manifests to apply:

flux/infra/{aws,baremetal}/keycloak/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - operator.yaml
  - pg-instance.yaml
  - kc.yaml
  - httproutes.yaml

Database Credentials Secret

Create the PostgreSQL database credentials as a SOPS-encrypted secret. Generate a strong password:

openssl rand -base64 24

Save the following as secrets/keycloak-admin.enc.yaml and encrypt it:

flux/infra/{aws,baremetal}/keycloak/secrets/keycloak-admin.enc.yaml
apiVersion: v1
kind: Secret
metadata:
  name: cnpg-keycloak-owner
  namespace: keycloak
type: kubernetes.io/basic-auth
stringData:
  username: keycloak
  password: <generated-password>
---
apiVersion: v1
kind: Secret
metadata:
  name: cnpg-keycloak-superuser
  namespace: keycloak
type: kubernetes.io/basic-auth
stringData:
  username: postgres
  password: <generated-password>
---
apiVersion: v1
kind: Secret
metadata:
  name: kc-crossplane-service
  namespace: keycloak
type: kubernetes.io/basic-auth
stringData:
  username: admin
  password: <generated-password>

Encrypt the file:

sops -i -e secrets/keycloak-admin.enc.yaml

Commit and Deploy

Once all files are in place, commit and push to trigger Flux deployment:

git add flux/infra/aws/keycloak/
git commit -m "feat(keycloak): add Keycloak identity management for AWS"
git push
git add flux/infra/baremetal/keycloak/
git commit -m "feat(keycloak): add Keycloak identity management for bare metal"
git push
git add flux/infra/baremetal/keycloak/
git commit -m "feat(keycloak): add Keycloak identity management for bare metal"
git push

Flux will detect the new commit and begin deploying Keycloak. To trigger an immediate sync:

flux reconcile kustomization infra-keycloak -n flux-system --with-source

Verify

After Keycloak is deployed, confirm it is working:

# Check that all pods are running
kubectl -n keycloak get pods

# Watch the Keycloak CR status
kubectl -n keycloak get keycloak kc -o wide

# Check the database is healthy
kubectl -n keycloak get cluster keycloak-postgres

# Get admin credentials from the secret
kubectl -n keycloak get secret kc-crossplane-service -o jsonpath='{.data.password}' | base64 -d

# Access Keycloak admin console (port-forward if needed)
kubectl -n keycloak port-forward svc/kc-service 8080:8080
# Open https://localhost:8080/admin (or https://keycloak.rciis.africa/admin if DNS/ingress is configured)

Flux Operations

This component is managed by Flux as Kustomization infra-keycloak.

Check whether the Kustomization is in a Ready state:

flux get kustomization infra-keycloak -n flux-system

Trigger an immediate sync — pulls the latest Git revision and re-applies the manifests. Use after pushing config changes:

flux reconcile kustomization infra-keycloak -n flux-system --with-source

View recent Flux controller logs — useful for diagnosing why a sync failed:

flux logs --kind=Kustomization --name=infra-keycloak -n flux-system

Realm, Client & Federation Configuration (Optional)

Keycloak resources — realms, clients, roles, and identity providers — can be configured declaratively using Crossplane with the provider-keycloak. This is an advanced integration beyond the scope of this guide. Refer to the Keycloak Operator documentation for manual realm setup via the admin console.


Next Steps

Keycloak is now deployed and running. Proceed to 5.3.5 Key Management to configure HSM-backed encryption for cryptographic keys.