Sync Backstage Users and Groups from LDAP (With Keycloak SSO, No Docker)

In a previous post,

I installed Backstage on Ubuntu 24.04, wired it to Keycloak for OIDC login, and ran it directly from source (no Docker, minimal code changes):

  • Backstage UI: https://backstage.maksonlee.com
  • Keycloak: https://keycloak.maksonlee.com (realm maksonlee.com)

In this follow-up, I:

  • Keep Keycloak for SSO (OIDC login).
  • Make Backstage users and groups come from LDAP (OpenDJ) via the official ldapOrg provider.
  • Run Backstage backend in production mode with a single NODE_ENV=production ... yarn --cwd packages/backend start command.

Architecture

Components:

  • LDAP (OpenDJ)
    • Base DN: dc=maksonlee,dc=com
    • Users: ou=people,dc=maksonlee,dc=com
    • Service accounts: ou=system,dc=maksonlee,dc=com
    • Groups: ou=organization,ou=groups,dc=maksonlee,dc=com
  • Keycloak
    • Realm: maksonlee.com
    • Backstage client: backstage (confidential)
    • Uses LDAP as user federation provider (user data ultimately lives in LDAP).
  • Backstage
    • URL: https://backstage.maksonlee.com
    • Backend DB: SQLite (better-sqlite3) in /var/lib/backstage/db
    • Authentication: OIDC via Keycloak
    • Organization: User and Group entities imported from LDAP with ldapOrg.

Flow:

  • User logs in to Backstage using Keycloak.
  • Keycloak authenticates against LDAP and returns an OIDC token with email.
  • Backstage uses emailMatchingUserEntityProfileEmail to map that email to a User entity in the catalog.
  • That User entity (and its Group memberships) are synced from LDAP.

Why sync from LDAP instead of Keycloak?

In my setup:

  • LDAP (OpenDJ) is the real source of truth for users and groups.
  • Keycloak is configured to use LDAP as its user provider (user federation).

That means the chain is already:

LDAP → Keycloak (for login)

If I also synced org data from Keycloak, I’d effectively have:

LDAP → Keycloak → Backstage

Backstage ships with a first-party LDAP org provider, so it’s simpler and more direct to:

  • Sync User and Group entities straight from LDAP, and
  • Use Keycloak only for OIDC (authentication + tokens).

In other words:

LDAP owns directory data,
Keycloak owns authentication,
Backstage ties them together.


LDAP layout details

My directory tree looks like this:

  • Base DN
    dc=maksonlee,dc=com
  • Users
    ou=people,dc=maksonlee,dc=com
    • Object class: inetOrgPerson
    • Example users:
      • uid=maksonlee
      • uid=jdoe
      • uid=asmith
      • uid=bchan
    • Key attributes:
      • uid
      • cn
      • mail
      • isMemberOf (contains group DNs)
  • Service accounts
    ou=system,dc=maksonlee,dc=com
    • Example: uid=backstage, uid=jenkins, …
  • Groups
    ou=organization,ou=groups,dc=maksonlee,dc=com
    • Object class: groupOfNames
    • Example: cn=engineering, cn=project-managers
    • Membership attribute: member (DNs of users)

We’ll map:

  • Users: uidmetadata.name, cn → display name, mail → email.
  • Groups: cn → metadata.name, member → spec.children.

Backstage & Keycloak recap

In app-config.production.yaml I already had:

app:
  baseUrl: https://backstage.maksonlee.com

backend:
  baseUrl: https://backstage.maksonlee.com
  listen: ':7007'
  database:
    client: better-sqlite3
    connection:
      directory: /var/lib/backstage/db

auth:
  environment: production
  session:
    secret: ${AUTH_SESSION_SECRET}
  providers:
    oidc:
      production:
        metadataUrl: https://keycloak.maksonlee.com/realms/maksonlee.com/.well-known/openid-configuration
        clientId: ${AUTH_OIDC_CLIENT_ID}
        clientSecret: ${AUTH_OIDC_CLIENT_SECRET}
        additionalScopes:
          - profile
          - email
        prompt: auto
        signIn:
          resolvers:
            - resolver: emailMatchingUserEntityProfileEmail

I reuse this exactly as-is; I only add LDAP as a catalog provider.


  1. Install the LDAP catalog backend module

From the Backstage repo:

cd ~/homelab-backstage
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-ldap

This installs the official ldapOrg provider for the catalog backend.


  1. Enable the LDAP module in packages/backend/src/index.ts

My backend uses the new backend system:

import { createBackend } from '@backstage/backend-defaults';

const backend = createBackend();

// app & proxy
backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-proxy-backend'));

// scaffolder
backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend-module-github'));
backend.add(
  import('@backstage/plugin-scaffolder-backend-module-notifications'),
);

// techdocs
backend.add(import('@backstage/plugin-techdocs-backend'));

// auth
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(import('@backstage/plugin-auth-backend-module-oidc-provider'));

// catalog
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(
  import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);

// catalog logs
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

// permission
backend.add(import('@backstage/plugin-permission-backend'));
backend.add(
  import('@backstage/plugin-permission-backend-module-allow-all-policy'),
);

// For simplicity this example uses the allow-all permission policy,
// which is not suitable for production. In a real deployment you
// should replace it with a custom policy that checks group membership.

// search
backend.add(import('@backstage/plugin-search-backend'));
backend.add(import('@backstage/plugin-search-backend-module-catalog'));
backend.add(import('@backstage/plugin-search-backend-module-techdocs'));

// kubernetes, notifications, signals
backend.add(import('@backstage/plugin-kubernetes-backend'));
backend.add(import('@backstage/plugin-notifications-backend'));
backend.add(import('@backstage/plugin-signals-backend'));

backend.start();

I added the LDAP module in the catalog section:

// catalog
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(
  import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);

// NEW: LDAP org provider
backend.add(import('@backstage/plugin-catalog-backend-module-ldap'));

// catalog logs
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

No other TypeScript changes required.


  1. Create a read-only bind user in LDAP

Under ou=system,dc=maksonlee,dc=com I created a dedicated service account:

  • DN: uid=backstage,ou=system,dc=maksonlee,dc=com
  • Object classes: e.g. inetOrgPerson
  • Attributes: uid, cn, sn, userPassword, etc.
  • ACLs: read access to:
    • ou=people,dc=maksonlee,dc=com
    • ou=organization,ou=groups,dc=maksonlee,dc=com

This account is only for Backstage to read users and groups.


  1. Configure catalog to use LDAP

Remove demo org data from catalog locations

In app-config.production.yaml, the original catalog section had:

catalog:
  locations:
    - type: file
      target: ./examples/entities.yaml

    - type: file
      target: ./examples/template/template.yaml
      rules:
        - allow: [Template]

    # Local example organizational data
    - type: file
      target: ./examples/org.yaml
      rules:
        - allow: [User, Group]

I commented out org.yaml so org data comes only from LDAP:

catalog:
  locations:
    - type: file
      target: ./examples/entities.yaml

    - type: file
      target: ./examples/template/template.yaml
      rules:
        - allow: [Template]

    # Org data now comes from LDAP, not from this example file.
    # - type: file
    #   target: ./examples/org.yaml
    #   rules:
    #     - allow: [User, Group]

(If examples/entities.yaml / template.yaml don’t exist in your repo, you can also comment those out to avoid warnings.)

Add ldapOrg provider

Still under catalog:

catalog:
  locations:
    - type: file
      target: ./examples/entities.yaml

    - type: file
      target: ./examples/template/template.yaml
      rules:
        - allow: [Template]

  providers:
    ldapOrg:
      default:
        target: ldaps://ldap.maksonlee.com

        bind:
          dn: "uid=backstage,ou=system,dc=maksonlee,dc=com"
          secret: ${LDAP_BIND_PASSWORD}

        schedule:
          frequency: PT1H
          timeout: PT15M
          initialDelay: PT3M

        users:
          - dn: "ou=people,dc=maksonlee,dc=com"
            options:
              scope: sub
              filter: "(&(objectClass=inetOrgPerson)(uid=*))"
            map:
              rdn: uid
              name: uid                # Backstage user:default/<uid>
              displayName: cn
              email: mail              # used by emailMatchingUserEntityProfileEmail
              memberOf: isMemberOf
            set:
              metadata.namespace: default

        groups:
          - dn: "ou=organization,ou=groups,dc=maksonlee,dc=com"
            options:
              scope: sub
              filter: "(objectClass=groupOfNames)"
            map:
              rdn: cn
              name: cn                 # Backstage group:default/<cn>
              displayName: cn
              description: description
              members: member          # DNs of users
            set:
              metadata.namespace: default
              # Optional: gives nicer UI headers instead of "UNKNOWN"
              # spec.type: team

This tells Backstage to:

  • Connect to LDAP over LDAPS.
  • Bind with uid=backstage,ou=system,….
  • Periodically read users and groups from LDAP and convert them into catalog entities.

  1. (Optional) reset the SQLite DB

If you previously loaded demo entities or want a clean slate:

sudo rm -rf /var/lib/backstage/db
sudo mkdir -p /var/lib/backstage/db
sudo chown "$USER":"$USER" /var/lib/backstage/db

Backstage will re-create the DB and run migrations at next startup.


  1. Start backend in production mode with env vars

I run the backend like this:

cd ~/homelab-backstage

NODE_ENV=production \
AUTH_SESSION_SECRET='your-32-byte-random-hex' \
AUTH_OIDC_CLIENT_ID='backstage' \
AUTH_OIDC_CLIENT_SECRET='your-real-keycloak-client-secret' \
LDAP_BIND_PASSWORD='your-ldap-bind-password' \
yarn --cwd packages/backend start \
  --config ../../app-config.yaml \
  --config ../../app-config.production.yaml

Notes:

  • AUTH_SESSION_SECRET can be any 32-byte random hex.
  • AUTH_OIDC_CLIENT_ID must match the Keycloak client ID (here backstage).
  • AUTH_OIDC_CLIENT_SECRET must be exactly the Keycloak client secret.
  • LDAP_BIND_PASSWORD is the password for uid=backstage,ou=system,….

  1. Verify LDAP sync from logs

When the backend starts, I see:

Registered scheduled task: LdapOrgEntityProvider:default:refresh, {"cadence":"PT1H","initialDelayDuration":"PT3M","timeoutAfterDuration":"PT15M"}

After the initial delay, the LDAP provider runs:

{"class":"LdapOrgEntityProvider","level":"info","message":"Reading LDAP users and groups", ...}
{"class":"LdapOrgEntityProvider","level":"info","message":"Read 4 LDAP users and 2 LDAP groups in 1.0 seconds. Committing...", ...}
{"class":"LdapOrgEntityProvider","level":"info","message":"Committed 4 LDAP users and 2 LDAP groups in 0.0 seconds.", ...}

That means:

  • Backstage successfully connected to LDAP.
  • It found 4 users and 2 groups.
  • They were written into the catalog DB.

If you want to force a refresh manually later, you can do it with the catalog refresh API plus authentication, but with the schedule in place it’s not necessary for this post.


  1. See users and groups in the Backstage UI

List all users

  • Go to My Company Catalog.
  • In the Kind dropdown (top-left), choose User.

You should see something like:

List all groups

  • Go to My Company Catalog.
  • In the Kind dropdown (top-left), choose Group.

You should see:

Did this guide save you time?

Support this site

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top