Run Backstage with Keycloak SSO on Ubuntu (Without Docker, Minimal Changes)

The official Backstage docs are excellent and very complete. They also move fast: Docker images, Postgres, Kubernetes, and a lot of pieces at once.

In this post I want to slow down and keep things simple:

  • A single Ubuntu VM
  • Backstage created by @backstage/create-app (no Docker)
  • HTTPS handled by HAProxy in front of Backstage
  • Authentication via Keycloak (OIDC)
  • Backstage users defined in a simple YAML catalog

Later posts will add:

  • PostgreSQL as the main Backstage database
  • LDAP / Keycloak user sync as the “source of truth” for users and groups

Here we focus on the minimal config changes on top of the default create-app output, so it’s easy to see exactly what you changed.

Note: We are not following the official Docker-based deployment guide here.
We run the Backstage backend directly with Node + Yarn on an Ubuntu VM, and put HAProxy with HTTPS in front of it.


  1. Environment overview

My setup for this post:

  • Host: Ubuntu 24.04 VM
  • Domain: https://backstage.maksonlee.com
  • TLS: HAProxy terminates HTTPS and forwards HTTP to Backstage on 127.0.0.1:7007
  • Auth: Keycloak realm maksonlee.com, client backstage, standard openid profile email scopes
  • Backstage user (catalog entity):
    • username: maksonlee
    • email: maksonlee@maksonlee.com
    • defined in packages/backend/examples/org.yaml

We’ll keep the Backstage side as close to the default create-app output as possible and only touch what we absolutely need:

  • app-config.production.yaml
  • packages/app/src/apis.ts
  • packages/app/src/App.tsx
  • packages/backend/src/index.ts
  • packages/backend/examples/org.yaml

  1. Prerequisites (Node, nvm, Yarn, Corepack)

I won’t re-explain the whole Node / nvm / npm / npx / Yarn / Corepack story here. I already wrote a dedicated post:

For this Backstage guide, I assume you have:

  • nvm installed and can do things like:
nvm install --lts
nvm use --lts
  • Corepack enabled for the Node version you use for Backstage:
corepack enable
  • A Backstage app already created, for example:
npx @backstage/create-app@latest
cd homelab-backstage
  • A Keycloak instance with:
    • Realm: maksonlee.com
    • Client: backstage (confidential client, standard OIDC)
  • A reverse proxy with TLS (I use HAProxy) that already has a valid certificate for
    backstage.maksonlee.com and can forward traffic to 127.0.0.1:7007.

I’ll show the Backstage-side changes and a minimal HAProxy backend/frontend snippet, but not how to obtain/renew the Let’s Encrypt certificate.


  1. Minimal production config: app-config.production.yaml

We start from the original app-config.production.yaml created by @backstage/create-app:

app:
  # Should be the same as backend.baseUrl when using the `app-backend` plugin.
  baseUrl: http://localhost:7007

backend:
  # Note that the baseUrl should be the URL that the browser and other clients
  # should use when communicating with the backend, i.e. it needs to be
  # reachable not just from within the backend host, but from all of your
  # callers. When its value is "http://localhost:7007", it's strictly private
  # and can't be reached by others.
  baseUrl: http://localhost:7007
  # The listener can also be expressed as a single <host>:<port> string. In this case we bind to
  # all interfaces, the most permissive setting. The right value depends on your specific deployment.
  listen: ':7007'

  # config options: https://node-postgres.com/apis/client
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: ${POSTGRES_PORT}
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      # https://node-postgres.com/features/ssl
      # you can set the sslmode configuration option via the `PGSSLMODE` environment variable
      # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require)
      # ssl:
      #   ca: # if you have a CA file and want to verify it you can uncomment this section
      #     $file: <file-path>/ca/server.crt

auth:
  providers:
    guest: {}

catalog:
  # Overrides the default list locations from app-config.yaml as these contain example data.
  # See https://backstage.io/docs/features/software-catalog/#adding-components-to-the-catalog for more details
  # on how to get entities into the catalog.
  locations:
    # Local example data, replace this with your production config, these are intended for demo use only.
    # File locations are relative to the backend process, typically in a deployed context, such as in a Docker container, this will be the root
    - type: file
      target: ./examples/entities.yaml

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

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

Now we apply only the minimal changes needed for:

  • Public URL (https://backstage.maksonlee.com)
  • Auth environment + session secret
  • OIDC provider for Keycloak
  • emailMatchingUserEntityProfileEmail sign-in resolver

Resulting app-config.production.yaml:

app:
  # Should be the same as backend.baseUrl when using the `app-backend` plugin.
  baseUrl: https://backstage.maksonlee.com

backend:
  # Note that the baseUrl should be the URL that the browser and other clients
  # should use when communicating with the backend, i.e. it needs to be
  # reachable not just from within the backend host, but from all of your
  # callers. When its value is "http://localhost:7007", it's strictly private
  # and can't be reached by others.
  baseUrl: https://backstage.maksonlee.com
  # The listener can also be expressed as a single <host>:<port> string. In this case we bind to
  # all interfaces, the most permissive setting. The right value depends on your specific deployment.
  listen: ':7007'

  # config options: https://node-postgres.com/apis/client
  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

catalog:
  # Overrides the default list locations from app-config.yaml as these contain example data.
  # See https://backstage.io/docs/features/software-catalog/#adding-components-to-the-catalog for more details
  # on how to get entities into the catalog.
  locations:
    # Local example data, replace this with your production config, these are intended for demo use only.
    # File locations are relative to the backend process, typically in a deployed context, such as in a Docker container, this will be the root
    - type: file
      target: ./examples/entities.yaml

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

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

Here we switch the production config to SQLite (better-sqlite3) under /var/lib/backstage/db, so PostgreSQL is not required yet. A later post will show how to move this to PostgreSQL.


  1. Frontend: register a Keycloak OIDC API and button
  • packages/app/src/apis.ts: add a Keycloak auth API

Original packages/app/src/apis.ts:

import {
  ScmIntegrationsApi,
  scmIntegrationsApiRef,
  ScmAuth,
} from '@backstage/integration-react';
import {
  AnyApiFactory,
  configApiRef,
  createApiFactory,
} from '@backstage/core-plugin-api';

export const apis: AnyApiFactory[] = [
  createApiFactory({
    api: scmIntegrationsApiRef,
    deps: { configApi: configApiRef },
    factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
  }),
  ScmAuth.createDefaultApiFactory(),
];

Minimal changes to add Keycloak OIDC support:

import {
  ScmIntegrationsApi,
  scmIntegrationsApiRef,
  ScmAuth,
} from '@backstage/integration-react';
import {
  AnyApiFactory,
  ApiRef,
  BackstageIdentityApi,
  OpenIdConnectApi,
  ProfileInfoApi,
  SessionApi,
  configApiRef,
  createApiFactory,
  createApiRef,
  discoveryApiRef,
  oauthRequestApiRef,
} from '@backstage/core-plugin-api';
import { OAuth2 } from '@backstage/core-app-api';

export const keycloakAuthApiRef: ApiRef<
  OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
  id: 'auth.keycloak',
});

export const apis: AnyApiFactory[] = [
  createApiFactory({
    api: scmIntegrationsApiRef,
    deps: { configApi: configApiRef },
    factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi),
  }),
  ScmAuth.createDefaultApiFactory(),

  // Keycloak OIDC auth (frontend)
  createApiFactory({
    api: keycloakAuthApiRef,
    deps: {
      discoveryApi: discoveryApiRef,
      oauthRequestApi: oauthRequestApiRef,
      configApi: configApiRef,
    },
    factory: ({ discoveryApi, oauthRequestApi, configApi }) =>
      OAuth2.create({
        configApi,
        discoveryApi,
        oauthRequestApi,
        provider: {
          // MUST be "oidc" to match auth.providers.oidc in app-config
          id: 'oidc',
          title: 'Keycloak',
          icon: () => null,
        },
        environment: configApi.getOptionalString('auth.environment'),
        defaultScopes: ['openid', 'profile', 'email'],
        popupOptions: {
          size: {
            fullscreen: true,
          },
        },
      }),
  }),
];
  • packages/app/src/App.tsx: add the Keycloak button to SignInPage

Original relevant snippet:

import { UserSettingsPage } from '@backstage/plugin-user-settings';
import { apis } from './apis';
...
import {
  AlertDisplay,
  OAuthRequestDialog,
  SignInPage,
} from '@backstage/core-components';
...
const app = createApp({
  apis,
  bindRoutes({ bind }) {
    ...
  },
  components: {
    SignInPage: props => <SignInPage {...props} auto providers={['guest']} />,
  },
});

Minimal changes:

import { UserSettingsPage } from '@backstage/plugin-user-settings';
import { apis, keycloakAuthApiRef } from './apis';
...
import {
  AlertDisplay,
  OAuthRequestDialog,
  SignInPage,
} from '@backstage/core-components';
...
const app = createApp({
  apis,
  bindRoutes({ bind }) {
    ...
  },
  components: {
    SignInPage: props => (
      <SignInPage
        {...props}
        auto
        providers={[
          {
            id: 'keycloak-auth-provider',
            title: 'Keycloak',
            message: 'Sign in using Keycloak',
            apiRef: keycloakAuthApiRef,
          },
        ]}
      />
    ),
  },
});

Everything else in App.tsx (routes, pages, etc.) stays exactly as generated.


  1. Backend auth wiring: packages/backend/src/index.ts

Original version from create-app:

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

const backend = createBackend();

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

// scaffolder plugin
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 plugin
backend.add(import('@backstage/plugin-techdocs-backend'));

// auth plugin
backend.add(import('@backstage/plugin-auth-backend'));
// See https://backstage.io/docs/backend-system/building-backends/migrating#the-auth-plugin
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
// See https://backstage.io/docs/auth/guest/provider

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

// See https://backstage.io/docs/features/software-catalog/configuration#subscribing-to-catalog-errors
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

// permission plugin
backend.add(import('@backstage/plugin-permission-backend'));
// See https://backstage.io/docs/permissions/getting-started for how to create your own permission policy
backend.add(
  import('@backstage/plugin-permission-backend-module-allow-all-policy'),
);

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

// search engine
// See https://backstage.io/docs/features/search/search-engines
backend.add(import('@backstage/plugin-search-backend-module-pg'));

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

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

// notifications and signals plugins
backend.add(import('@backstage/plugin-notifications-backend'));
backend.add(import('@backstage/plugin-signals-backend'));

backend.start();

Minimal changes to use the new authModule default export and add the OIDC auth backend module:

/*
 * Hi!
 *
 * Note that this is an EXAMPLE Backstage backend. Please check the README.
 *
 * Happy hacking!
 */

import { createBackend } from '@backstage/backend-defaults';
// Use the default export as a backend feature
import authModule from '@backstage/plugin-auth-backend';

const backend = createBackend();

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

// scaffolder plugin
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 plugin
backend.add(import('@backstage/plugin-techdocs-backend'));

// auth plugin (new backend system)
backend.add(authModule);

// Keycloak OIDC auth provider module
backend.add(import('@backstage/plugin-auth-backend-module-oidc-provider'));
// Guest auth is disabled; only Keycloak OIDC is used in this setup.

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

// Subscribe to catalog errors and expose them via logs
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

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

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

// search engine (Postgres). You can comment this out if you’re not ready
// to use PostgreSQL as the search index yet.
// backend.add(import('@backstage/plugin-search-backend-module-pg'));

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

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

// notifications and signals plugins
backend.add(import('@backstage/plugin-notifications-backend'));
backend.add(import('@backstage/plugin-signals-backend'));

backend.start();

If you don’t want to use Postgres for search yet, just comment out the plugin-search-backend-module-pg line as shown.


  1. Add your user and group to the catalog

Backstage will not auto-create catalog User entities from Keycloak.
Instead, the OIDC sign-in resolver we chose:

signIn:
  resolvers:
    - resolver: emailMatchingUserEntityProfileEmail

matches:

  • Keycloak email ↔ spec.profile.email of a Backstage User.

For your user maksonlee with email maksonlee@maksonlee.com, create:

packages/backend/examples/org.yaml

apiVersion: backstage.io/v1alpha1
kind: User
metadata:
  name: maksonlee
spec:
  profile:
    displayName: Makson Lee
    email: maksonlee@maksonlee.com
  memberOf:
    - homelab-admins

---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
  name: homelab-admins
spec:
  type: team
  profile:
    displayName: Homelab Admins
  children: []

Because app-config.production.yaml already includes:

    - type: file
      target: ./examples/org.yaml
      rules:
        - allow: [User, Group]

the catalog backend will load this file and register your User and Group.


  1. HAProxy: HTTPS in front of Backstage

Again: we’re not using the official Docker way. Backstage listens on 127.0.0.1:7007 with plain HTTP, and HAProxy terminates TLS and forwards requests.

A minimal HAProxy config snippet:

frontend fe_https
        bind *:443 ssl crt /etc/haproxy/certs/backstage.maksonlee.com.pem
        mode http
        option httplog
        option forwardfor

        http-request set-header X-Forwarded-Proto https if { ssl_fc }
        http-request set-header X-Forwarded-Host  %[req.hdr(Host)]

        default_backend be_backstage

backend be_backstage
        mode http

        # Use Backstage readiness endpoint instead of /healthcheck
        option httpchk GET /.backstage/health/v1/readiness
        http-check expect status 200

        server backstage1 127.0.0.1:7007 check

What this does:

  • Terminates HTTPS for backstage.maksonlee.com
  • Runs a health check on /.backstage/health/v1/readiness
  • Forwards valid traffic to the Backstage backend

How you obtained the Let’s Encrypt certificate is out of scope for this post.


  1. Install the OIDC backend module, type-check, and start Backstage in production mode

From the repo root (~/homelab-backstage):

cd ~/homelab-backstage

# 1) Install dependencies (if you haven’t already)
yarn install

# 2) Add the OIDC backend module for Keycloak (run once)
yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-oidc-provider

# 3) Optional: Type-check the repo
yarn tsc

# 4) Build the frontend app so /packages/app/dist exists
yarn --cwd packages/app build

# 5) Start the backend in production mode
NODE_ENV=production \
AUTH_SESSION_SECRET='a-long-random-string-here' \
AUTH_OIDC_CLIENT_ID='backstage' \
AUTH_OIDC_CLIENT_SECRET='your-keycloak-client-secret' \
yarn --cwd packages/backend start \
  --config ../../app-config.yaml \
  --config ../../app-config.production.yaml

You should see log lines like:

  • Listening on 0.0.0.0:7007
  • Configuring auth provider: oidc
  • Plugin initialization complete, newly initialized: ...
  • Periodic GET /.backstage/health/v1/readiness 200 from HAProxy

Now visit:

https://backstage.maksonlee.com

You should see the Backstage UI and a sign-in dialog with:

  • Keycloak

Click Keycloak, log in as maksonlee in Keycloak (with email maksonlee@maksonlee.com), and Backstage should:

  • Complete the OIDC flow,
  • Resolve your identity via emailMatchingUserEntityProfileEmail,
  • Map you to the User entity from org.yaml.

If the email doesn’t match, you’ll see errors like:

“Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities…”

In that case, double-check:

  • Keycloak user email
  • spec.profile.email in org.yaml

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