This post shows how I deploy my Backstage app (homelab-backstage) into Kubernetes while keeping my existing Backstage instance unchanged.
This guide assumes you already have:
- Existing Backstage (unchanged):
https://backstage.maksonlee.com - New Kubernetes Backstage:
https://backstage-k8s.maksonlee.com - Container image:
harbor.maksonlee.com/backstage/homelab-backstage:test(Harbor is publicly readable) - Backstage DB: PostgreSQL deployed inside Kubernetes (StatefulSet + PVC)
- K8s files live in:
~/homelab-backstage/kubernetes/
What you’ll build
In Kubernetes:
- Namespace:
backstage - Secret:
backstage-env(created fromkubernetes/.env.k8s) - ConfigMap:
backstage-k8s-config(mountsapp-config.k8s.yaml) - PostgreSQL:
- Service:
backstage-postgres - StatefulSet:
backstage-postgres - PVC:
data-backstage-postgres-0(20Gi)
- Service:
- Backstage:
- Deployment:
homelab-backstage - Service:
homelab-backstage(port 80 → 7007) - Ingress:
backstage-k8s.maksonlee.comvia Traefik
- Deployment:
Prerequisites
- A working Kubernetes cluster (kubeadm + containerd is fine)
- Traefik is installed and HTTPS is already enabled via a default wildcard certificate (TLSStore) plus global HTTP→HTTPS redirect for
*.maksonlee.com. - DNS points to your Ingress external IP (example: MetalLB IP)
backstage-k8s.maksonlee.com→<Traefik external IP>
- Harbor is reachable from Kubernetes nodes:
harbor.maksonlee.com
- Your Backstage repo exists locally:
~/homelab-backstage
- Prepare the Kubernetes folder
cd ~/homelab-backstage
mkdir -p kubernetes
Target layout:
~/homelab-backstage/kubernetes/
.env.k8s
app-config.k8s.yaml
homelab-backstage.yaml
postgres.yaml
- Create
kubernetes/.env.k8s
Create a separate env file for the Kubernetes instance (because this instance uses a new Keycloak client for backstage-k8s.maksonlee.com).
Example variables (yours will contain real values):
AUTH_SESSION_SECRET=REPLACE_ME
AUTH_OIDC_CLIENT_ID=REPLACE_ME
AUTH_OIDC_CLIENT_SECRET=REPLACE_ME
LDAP_BIND_PASSWORD=REPLACE_ME
GITHUB_TOKEN=REPLACE_ME
GERRIT_USERNAME=REPLACE_ME
GERRIT_TOKEN=REPLACE_ME
JENKINS_USERNAME=REPLACE_ME
JENKINS_API_TOKEN=REPLACE_ME
ARTIFACTORY_AUTH_HEADER=REPLACE_ME
POSTGRES_HOST=REPLACE_ME
POSTGRES_PORT=REPLACE_ME
POSTGRES_USER=REPLACE_ME
POSTGRES_PASSWORD=REPLACE_ME
POSTGRES_DB=REPLACE_ME
For in-cluster Postgres, the important DB values are typically:
POSTGRES_HOST=backstage-postgres
POSTGRES_PORT=5432
POSTGRES_USER=backstage
POSTGRES_PASSWORD=change_me_now
POSTGRES_DB=backstage
- Add
kubernetes/.env.k8sto.gitignore
Add this line to your repo .gitignore:
kubernetes/.env.k8s
- Create a new Keycloak client for the k8s hostname
In Keycloak (realm maksonlee.com), create a new OIDC client for the Kubernetes instance (so it can use backstage-k8s.maksonlee.com as a valid redirect/origin).
- Client ID: (example)
backstage-k8s - Client type: confidential (client secret enabled)
- Redirect URIs should include Backstage auth handler endpoints under:
https://backstage-k8s.maksonlee.com/...
Then put that client ID/secret into:
kubernetes/.env.k8s:AUTH_OIDC_CLIENT_ID=...AUTH_OIDC_CLIENT_SECRET=...
- Build and push the Backstage image to Harbor
Build in the repo:
cd ~/homelab-backstage
yarn install --immutable
yarn tsc
yarn build:backend
docker build -f packages/backend/Dockerfile -t homelab-backstage:local .
Tag and push:
docker login harbor.maksonlee.com
docker tag homelab-backstage:local \
harbor.maksonlee.com/backstage/homelab-backstage:test
docker push harbor.maksonlee.com/backstage/homelab-backstage:test
Since Harbor is publicly readable, we don’t need imagePullSecrets.
- Create
kubernetes/app-config.k8s.yaml
This file is a minimal override for the Kubernetes instance.
Use this file exactly:
app:
baseUrl: https://backstage-k8s.maksonlee.com
backend:
baseUrl: https://backstage-k8s.maksonlee.com
listen: ':7007'
cors:
origin: https://backstage-k8s.maksonlee.com
credentials: true
- Create the namespace
kubectl create namespace backstage
- Create the Secret from
kubernetes/.env.k8s
This Secret will be used by both Backstage and PostgreSQL.
cd ~/homelab-backstage
kubectl -n backstage create secret generic backstage-env \
--from-env-file=kubernetes/.env.k8s \
--dry-run=client -o yaml | kubectl apply -f -
Quick verify one value (example):
kubectl -n backstage get secret backstage-env -o jsonpath='{.data.POSTGRES_HOST}' | base64 -d; echo
- Create the ConfigMap from
app-config.k8s.yaml
cd ~/homelab-backstage
kubectl -n backstage create configmap backstage-k8s-config \
--from-file=app-config.k8s.yaml=kubernetes/app-config.k8s.yaml \
--dry-run=client -o yaml | kubectl apply -f -
- Deploy PostgreSQL in Kubernetes
Create kubernetes/postgres.yaml.
Use this file exactly:
apiVersion: v1
kind: Service
metadata:
name: backstage-postgres
namespace: backstage
spec:
type: ClusterIP
selector:
app: backstage-postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: backstage-postgres
namespace: backstage
spec:
serviceName: backstage-postgres
replicas: 1
selector:
matchLabels:
app: backstage-postgres
template:
metadata:
labels:
app: backstage-postgres
spec:
securityContext:
fsGroup: 999
containers:
- name: postgres
image: postgres:16
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: backstage-env
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: backstage-env
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: backstage-env
key: POSTGRES_DB
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
# If you want to pin a StorageClass, uncomment:
# storageClassName: YOUR_STORAGECLASS
Apply:
kubectl apply -f kubernetes/postgres.yaml
Wait for it:
kubectl -n backstage get pvc
kubectl -n backstage get pods
Expected:
data-backstage-postgres-0isBoundbackstage-postgres-0isRunning
- Verify PostgreSQL from inside the cluster
Run a one-off psql pod that loads env vars from the same Secret:
kubectl -n backstage run psql-test --rm -it --restart=Never --image=postgres:16 \
--overrides='{
"apiVersion":"v1",
"spec":{
"restartPolicy":"Never",
"containers":[{
"name":"psql",
"image":"postgres:16",
"stdin":true,
"tty":true,
"envFrom":[{"secretRef":{"name":"backstage-env"}}],
"command":["sh","-lc","PGPASSWORD=\"$POSTGRES_PASSWORD\" psql -h \"$POSTGRES_HOST\" -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -c \"select now();\""]
}]
}
}'
If it prints a now() timestamp row, Postgres is ready.
- Deploy Backstage (Deployment + Service + Ingress)
Create kubernetes/homelab-backstage.yaml.
Use this file exactly:
apiVersion: apps/v1
kind: Deployment
metadata:
name: homelab-backstage
namespace: backstage
spec:
replicas: 1
selector:
matchLabels:
app: homelab-backstage
template:
metadata:
labels:
app: homelab-backstage
spec:
containers:
- name: homelab-backstage
image: harbor.maksonlee.com/backstage/homelab-backstage:test
imagePullPolicy: Always
ports:
- name: http
containerPort: 7007
# Load env vars (OIDC client for k8s, plus your existing secrets)
envFrom:
- secretRef:
name: backstage-env
# Mount the minimal k8s override config
volumeMounts:
- name: k8s-config
mountPath: /app/app-config.k8s.yaml
subPath: app-config.k8s.yaml
# Start Backstage with your existing configs + the k8s override
command: ["node"]
args:
- "packages/backend"
- "--config"
- "app-config.yaml"
- "--config"
- "app-config.production.yaml"
- "--config"
- "app-config.k8s.yaml"
volumes:
- name: k8s-config
configMap:
name: backstage-k8s-config
---
apiVersion: v1
kind: Service
metadata:
name: homelab-backstage
namespace: backstage
spec:
type: ClusterIP
selector:
app: homelab-backstage
ports:
- name: http
port: 80
targetPort: 7007
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homelab-backstage
namespace: backstage
spec:
ingressClassName: traefik
rules:
- host: backstage-k8s.maksonlee.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: homelab-backstage
port:
number: 80
This Ingress has no tls: block because Traefik is already configured with a default TLS certificate and HTTP→HTTPS redirect.
Apply:
kubectl apply -f kubernetes/homelab-backstage.yaml
Watch status:
kubectl -n backstage get pods
kubectl -n backstage logs deploy/homelab-backstage -f
- Verify the site
Check the Ingress:
kubectl -n backstage get ingress
Test from a client:
curl -I http://backstage-k8s.maksonlee.com
Open in browser:
- https://backstage-k8s.maksonlee.com
Updating configuration and redeploying
Update .env.k8s
After editing kubernetes/.env.k8s:
kubectl -n backstage create secret generic backstage-env \
--from-env-file=kubernetes/.env.k8s \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n backstage rollout restart deploy/homelab-backstage
Update app-config.k8s.yaml
After editing kubernetes/app-config.k8s.yaml:
kubectl -n backstage create configmap backstage-k8s-config \
--from-file=app-config.k8s.yaml=kubernetes/app-config.k8s.yaml \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n backstage rollout restart deploy/homelab-backstage
Update the Backstage image
Push a new tag, then set the image:
kubectl -n backstage set image deploy/homelab-backstage \
homelab-backstage=harbor.maksonlee.com/backstage/homelab-backstage:NEW_TAG
kubectl -n backstage rollout status deploy/homelab-backstage
Did this guide save you time?
Support this site