From eb39a1cd85afcd124991b0a9cfa694c60ecc9123 Mon Sep 17 00:00:00 2001 From: Backstage Deploy Date: Wed, 14 Jan 2026 15:09:56 +0530 Subject: [PATCH] Initial Backstage Helm chart with ESO integration --- Chart.yaml | 12 ++ templates/_helpers.tpl | 60 ++++++++++ templates/backstage-configmap.yaml | 123 ++++++++++++++++++++ templates/backstage-deployment.yaml | 129 ++++++++++++++++++++ templates/backstage-ingress.yaml | 40 +++++++ templates/backstage-rbac.yaml | 51 ++++++++ templates/backstage-service.yaml | 16 +++ templates/cloudsql-proxy-deployment.yaml | 48 ++++++++ templates/cloudsql-proxy-service.yaml | 19 +++ templates/externalsecret.yaml | 20 ++++ templates/secretstore.yaml | 19 +++ templates/serviceaccount.yaml | 30 +++++ values.yaml | 142 +++++++++++++++++++++++ 13 files changed, 709 insertions(+) create mode 100644 Chart.yaml create mode 100644 templates/_helpers.tpl create mode 100644 templates/backstage-configmap.yaml create mode 100644 templates/backstage-deployment.yaml create mode 100644 templates/backstage-ingress.yaml create mode 100644 templates/backstage-rbac.yaml create mode 100644 templates/backstage-service.yaml create mode 100644 templates/cloudsql-proxy-deployment.yaml create mode 100644 templates/cloudsql-proxy-service.yaml create mode 100644 templates/externalsecret.yaml create mode 100644 templates/secretstore.yaml create mode 100644 templates/serviceaccount.yaml create mode 100644 values.yaml diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..9c8b51f --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: backstage +description: Backstage Developer Portal for ephemeral testing environments +type: application +version: 1.0.0 +appVersion: "1.28.4" +keywords: + - backstage + - developer-portal + - platform-engineering +maintainers: + - name: OnlineSales Platform Team diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl new file mode 100644 index 0000000..b59da18 --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "backstage.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "backstage.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "backstage.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "backstage.labels" -}} +helm.sh/chart: {{ include "backstage.chart" . }} +{{ include "backstage.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "backstage.selectorLabels" -}} +app.kubernetes.io/name: {{ include "backstage.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Service account name +*/}} +{{- define "backstage.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "backstage.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/templates/backstage-configmap.yaml b/templates/backstage-configmap.yaml new file mode 100644 index 0000000..c9d163b --- /dev/null +++ b/templates/backstage-configmap.yaml @@ -0,0 +1,123 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: backstage-config + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} +data: + app-config.production.yaml: | + app: + title: {{ .Values.backstage.title }} + baseUrl: {{ .Values.backstage.baseUrl }} + + organization: + name: {{ .Values.backstage.organization }} + + backend: + baseUrl: {{ .Values.backstage.baseUrl }} + listen: + port: {{ .Values.service.targetPort }} + host: 0.0.0.0 + + csp: + connect-src: ["'self'", 'http:', 'https:'] + + cors: + origin: {{ .Values.backstage.baseUrl }} + methods: [GET, HEAD, PATCH, POST, PUT, DELETE] + credentials: true + + database: + client: pg + connection: + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + database: {{ .Values.database.name }} + ssl: + rejectUnauthorized: false + + auth: + keys: + - secret: ${BACKEND_SECRET} + + auth: + environment: production + session: + secret: ${BACKEND_SECRET} + providers: + google: + production: + clientId: ${AUTH_GOOGLE_CLIENT_ID} + clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} + + integrations: + gitea: + - baseUrl: {{ .Values.integrations.gitea.url }} + host: {{ .Values.integrations.gitea.url | replace "https://" "" | replace "http://" "" }} + username: ${GITEA_USERNAME} + password: ${GITEA_PASSWORD} + + proxy: + endpoints: + '/argo-workflows/api': + target: '{{ .Values.integrations.argoWorkflows.url }}' + changeOrigin: true + secure: true + headers: + Accept: 'application/json' + Authorization: 'Bearer ${ARGO_WORKFLOWS_TOKEN}' + + '/argocd/api': + target: '{{ .Values.integrations.argocd.url }}' + changeOrigin: true + secure: true + + techdocs: + builder: 'local' + generator: + runIn: 'local' + publisher: + type: 'local' + + scaffolder: + defaultAuthor: + name: backstage-scaffolder + email: noreply@{{ .Values.backstage.baseUrl | replace "https://" "" | replace "http://" "" }} + defaultCommitMessage: "backstage scaffolder" + + catalog: + import: + entityFilename: catalog-info.yaml + pullRequestBranchName: backstage-integration + rules: + - allow: [Component, System, API, Resource, Location, Template] + locations: + - type: url + target: {{ .Values.integrations.gitea.url }}/templates/catalog-info.yaml + + kubernetes: + serviceLocatorMethod: + type: 'multiTenant' + clusterLocatorMethods: + - type: 'config' + clusters: + - url: {{ .Values.integrations.kubernetes.clusterUrl }} + name: backstage-cluster + authProvider: 'serviceAccount' + skipTLSVerify: false + skipMetricsLookup: false + + argocd: + appLocatorMethods: + - type: 'config' + instances: + - name: production + url: {{ .Values.integrations.argocd.url }} + username: admin + password: ${ARGOCD_AUTH_TOKEN} + + argoWorkflows: + baseUrl: {{ .Values.integrations.argoWorkflows.url }} diff --git a/templates/backstage-deployment.yaml b/templates/backstage-deployment.yaml new file mode 100644 index 0000000..ceff43e --- /dev/null +++ b/templates/backstage-deployment.yaml @@ -0,0 +1,129 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "backstage.fullname" . }} + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "backstage.selectorLabels" . | nindent 6 }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + {{- include "backstage.selectorLabels" . | nindent 8 }} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "{{ .Values.service.targetPort }}" + prometheus.io/path: "/metrics" + spec: + serviceAccountName: {{ include "backstage.serviceAccountName" . }} + automountServiceAccountToken: true + securityContext: + fsGroup: 1000 + runAsUser: 1000 + runAsNonRoot: true + containers: + - name: backstage + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + - name: NODE_ENV + value: "production" + # Database credentials from secret + - name: POSTGRES_HOST + valueFrom: + secretKeyRef: + name: backstage-secrets + key: POSTGRES_HOST + - name: POSTGRES_PORT + valueFrom: + secretKeyRef: + name: backstage-secrets + key: POSTGRES_PORT + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: backstage-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: backstage-secrets + key: POSTGRES_PASSWORD + # OAuth credentials + - name: AUTH_GOOGLE_CLIENT_ID + valueFrom: + secretKeyRef: + name: backstage-secrets + key: AUTH_GOOGLE_CLIENT_ID + - name: AUTH_GOOGLE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: backstage-secrets + key: AUTH_GOOGLE_CLIENT_SECRET + # Backend secret + - name: BACKEND_SECRET + valueFrom: + secretKeyRef: + name: backstage-secrets + key: BACKEND_SECRET + # ArgoCD integration + - name: ARGOCD_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: backstage-secrets + key: ARGOCD_AUTH_TOKEN + # Argo Workflows integration + - name: ARGO_WORKFLOWS_TOKEN + valueFrom: + secretKeyRef: + name: backstage-secrets + key: ARGO_WORKFLOWS_TOKEN + # Gitea integration + - name: GITEA_USERNAME + valueFrom: + secretKeyRef: + name: backstage-secrets + key: GITEA_USERNAME + - name: GITEA_PASSWORD + valueFrom: + secretKeyRef: + name: backstage-secrets + key: GITEA_PASSWORD + volumeMounts: + - name: config + mountPath: /app/app-config.production.yaml + subPath: app-config.production.yaml + readOnly: true + command: + - node + - packages/backend + - --config + - app-config.production.yaml + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 10 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 10 }} + volumes: + - name: config + configMap: + name: backstage-config + restartPolicy: Always diff --git a/templates/backstage-ingress.yaml b/templates/backstage-ingress.yaml new file mode 100644 index 0000000..dadeb89 --- /dev/null +++ b/templates/backstage-ingress.yaml @@ -0,0 +1,40 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "backstage.fullname" . }} + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "backstage.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/templates/backstage-rbac.yaml b/templates/backstage-rbac.yaml new file mode 100644 index 0000000..99a7b4e --- /dev/null +++ b/templates/backstage-rbac.yaml @@ -0,0 +1,51 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: backstage-environment-manager + labels: + {{- include "backstage.labels" . | nindent 4 }} +rules: + # Namespace management for environment lifecycle + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch", "delete"] + + # Read access to environment resources for display + - apiGroups: [""] + resources: ["resourcequotas", "limitranges", "pods", "services"] + verbs: ["get", "list", "watch"] + + - apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list", "watch"] + + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "list", "watch"] + + # ArgoCD Applications - for cleanup when deleting environments + - apiGroups: ["argoproj.io"] + resources: ["applications"] + verbs: ["get", "list", "watch", "delete"] + + # Argo Workflows - for monitoring environment provisioning status + - apiGroups: ["argoproj.io"] + resources: ["workflows"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: backstage-environment-manager-binding + labels: + {{- include "backstage.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: backstage-environment-manager +subjects: + - kind: ServiceAccount + name: {{ include "backstage.serviceAccountName" . }} + namespace: {{ .Values.namespaceOverride }} +{{- end }} diff --git a/templates/backstage-service.yaml b/templates/backstage-service.yaml new file mode 100644 index 0000000..4d87ce4 --- /dev/null +++ b/templates/backstage-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "backstage.fullname" . }} + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + protocol: TCP + targetPort: {{ .Values.service.targetPort }} + selector: + {{- include "backstage.selectorLabels" . | nindent 4 }} diff --git a/templates/cloudsql-proxy-deployment.yaml b/templates/cloudsql-proxy-deployment.yaml new file mode 100644 index 0000000..8fbcde5 --- /dev/null +++ b/templates/cloudsql-proxy-deployment.yaml @@ -0,0 +1,48 @@ +{{- if .Values.cloudSqlProxy.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage-cloudsql-proxy + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} + app: backstage-cloudsql-proxy +spec: + replicas: 1 + selector: + matchLabels: + app: backstage-cloudsql-proxy + template: + metadata: + labels: + app: backstage-cloudsql-proxy + {{- include "backstage.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ .Values.cloudSqlProxyServiceAccount.name }} + automountServiceAccountToken: true + securityContext: + fsGroup: 65532 + runAsUser: 65532 + runAsNonRoot: true + containers: + - name: cloud-sql-proxy + image: "{{ .Values.cloudSqlProxy.image.repository }}:{{ .Values.cloudSqlProxy.image.tag }}" + args: + - "{{ .Values.cloudSqlProxy.connectionName }}" + - "--port={{ .Values.cloudSqlProxy.port }}" + - "--address=0.0.0.0" + {{- if .Values.cloudSqlProxy.privateIp }} + - "--private-ip" + {{- end }} + ports: + - name: postgres + containerPort: {{ .Values.cloudSqlProxy.port }} + protocol: TCP + securityContext: + runAsNonRoot: true + runAsUser: 65532 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + resources: + {{- toYaml .Values.cloudSqlProxy.resources | nindent 10 }} +{{- end }} diff --git a/templates/cloudsql-proxy-service.yaml b/templates/cloudsql-proxy-service.yaml new file mode 100644 index 0000000..48472e3 --- /dev/null +++ b/templates/cloudsql-proxy-service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.cloudSqlProxy.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: backstage-cloudsql-proxy + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} + app: backstage-cloudsql-proxy +spec: + type: ClusterIP + ports: + - name: postgresql + port: {{ .Values.cloudSqlProxy.port }} + protocol: TCP + targetPort: {{ .Values.cloudSqlProxy.port }} + selector: + app: backstage-cloudsql-proxy +{{- end }} diff --git a/templates/externalsecret.yaml b/templates/externalsecret.yaml new file mode 100644 index 0000000..0392299 --- /dev/null +++ b/templates/externalsecret.yaml @@ -0,0 +1,20 @@ +{{- if .Values.externalSecrets.enabled -}} +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: backstage-secrets + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} +spec: + refreshInterval: {{ .Values.externalSecrets.refreshInterval }} + secretStoreRef: + name: {{ .Values.externalSecrets.secretStoreRef }} + kind: SecretStore + target: + name: backstage-secrets + creationPolicy: Owner + dataFrom: + - extract: + key: {{ .Values.externalSecrets.secretName }} +{{- end }} diff --git a/templates/secretstore.yaml b/templates/secretstore.yaml new file mode 100644 index 0000000..abae0c6 --- /dev/null +++ b/templates/secretstore.yaml @@ -0,0 +1,19 @@ +{{- if .Values.externalSecrets.enabled -}} +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: {{ .Values.externalSecrets.secretStoreRef }} + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} +spec: + provider: + gcpsm: + projectID: {{ .Values.externalSecrets.projectId | quote }} + auth: + workloadIdentity: + clusterLocation: {{ .Values.externalSecrets.clusterLocation }} + clusterName: {{ .Values.externalSecrets.clusterName }} + serviceAccountRef: + name: {{ .Values.serviceAccount.name }} +{{- end }} diff --git a/templates/serviceaccount.yaml b/templates/serviceaccount.yaml new file mode 100644 index 0000000..a6d99e7 --- /dev/null +++ b/templates/serviceaccount.yaml @@ -0,0 +1,30 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccount.name }} + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: true +{{- end }} +{{- if .Values.cloudSqlProxyServiceAccount.create }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.cloudSqlProxyServiceAccount.name }} + namespace: {{ .Values.namespaceOverride }} + labels: + {{- include "backstage.labels" . | nindent 4 }} + app: backstage-cloudsql-proxy + {{- with .Values.cloudSqlProxyServiceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: true +{{- end }} diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..ea9cbe8 --- /dev/null +++ b/values.yaml @@ -0,0 +1,142 @@ +# Global settings +nameOverride: "" +fullnameOverride: "backstage" +namespaceOverride: "backstage" + +# Backstage application image +image: + repository: gcr.io/prj-onlinesales-test-framework/backstage + tag: "v1.0.0" + pullPolicy: IfNotPresent + +# Replica count +replicaCount: 2 + +# Service configuration +service: + type: ClusterIP + port: 80 + targetPort: 7007 + +# Ingress configuration +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/backend-protocol: "HTTP" + nginx.ingress.kubernetes.io/proxy-body-size: "100m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" + hosts: + - host: backstage.os-tf-qa.onlinesales.ai + paths: + - path: / + pathType: Prefix + tls: + - secretName: backstage-tls + hosts: + - backstage.os-tf-qa.onlinesales.ai + +# Resources +resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + +# Health checks +livenessProbe: + httpGet: + path: /healthcheck + port: 7007 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /healthcheck + port: 7007 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +# Backstage configuration +backstage: + baseUrl: https://backstage.os-tf-qa.onlinesales.ai + title: "OnlineSales Test Framework" + organization: "OnlineSales" + +# Database configuration (Cloud SQL via proxy) +database: + host: backstage-cloudsql-proxy + port: 5432 + user: backstage-user + name: backstage + +# Cloud SQL Proxy configuration +cloudSqlProxy: + enabled: true + image: + repository: gcr.io/cloud-sql-connectors/cloud-sql-proxy + tag: "2.14.0" + connectionName: "prj-onlinesales-test-framework:asia-south1:backstage-db" + port: 5432 + privateIp: true + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + +# External Secrets Operator configuration +externalSecrets: + enabled: true + projectId: "prj-onlinesales-test-framework" + clusterLocation: "asia-south1" + clusterName: "backstage-cluster" + refreshInterval: "1h" + secretStoreRef: "gcpsm-secret-store" + # Single secret with all key-value pairs + secretName: "backstage-secrets" + +# Integrations +integrations: + argocd: + url: https://argocd.os-tf-qa.onlinesales.ai + argoWorkflows: + url: https://argo-workflows.os-tf-qa.onlinesales.ai + gitea: + url: https://gitea.os-tf-qa.onlinesales.ai + kubernetes: + clusterUrl: https://35.200.246.59 + +# RBAC +rbac: + create: true + +# Service accounts +serviceAccount: + create: true + name: backstage + annotations: + iam.gke.io/gcp-service-account: backstage-workload@prj-onlinesales-test-framework.iam.gserviceaccount.com + +cloudSqlProxyServiceAccount: + create: true + name: backstage-cloudsql + annotations: + iam.gke.io/gcp-service-account: backstage-workload@prj-onlinesales-test-framework.iam.gserviceaccount.com + +# Namespace +namespace: + create: false # Already created by Terraform + name: backstage