Deploy Backstage to Kubernetes (Traefik Ingress, In-Cluster PostgreSQL, Harbor)

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 from kubernetes/.env.k8s)
  • ConfigMap: backstage-k8s-config (mounts app-config.k8s.yaml)
  • PostgreSQL:
    • Service: backstage-postgres
    • StatefulSet: backstage-postgres
    • PVC: data-backstage-postgres-0 (20Gi)
  • Backstage:
    • Deployment: homelab-backstage
    • Service: homelab-backstage (port 80 → 7007)
    • Ingress: backstage-k8s.maksonlee.com via Traefik

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

  1. 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

  1. 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

  1. Add kubernetes/.env.k8s to .gitignore

Add this line to your repo .gitignore:

kubernetes/.env.k8s

  1. 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=...

  1. 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.


  1. 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

  1. Create the namespace
kubectl create namespace backstage

  1. 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

  1. 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 -

  1. 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-0 is Bound
  • backstage-postgres-0 is Running

  1. 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.


  1. 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

  1. 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

Leave a Comment

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

Scroll to Top