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.
- 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, clientbackstage, standardopenid profile emailscopes - Backstage user (catalog entity):
- username:
maksonlee - email:
maksonlee@maksonlee.com - defined in
packages/backend/examples/org.yaml
- username:
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.yamlpackages/app/src/apis.tspackages/app/src/App.tsxpackages/backend/src/index.tspackages/backend/examples/org.yaml
- 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)
- Realm:
- A reverse proxy with TLS (I use HAProxy) that already has a valid certificate for
backstage.maksonlee.comand can forward traffic to127.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.
- 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
emailMatchingUserEntityProfileEmailsign-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.
- 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 toSignInPage
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.
- 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.
- 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.emailof a BackstageUser.
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.
- 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.
- 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:7007Configuring auth provider: oidcPlugin initialization complete, newly initialized: ...- Periodic
GET /.backstage/health/v1/readiness 200from 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
Userentity fromorg.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.emailinorg.yaml
Did this guide save you time?
Support this site