Deploying a Backstage app on Kubernetes

Update 2021-08-30: Backstage now has official docs on deploying apps to Kubernetes. You should reference those instead of this post.

The first time I heard about Backstage, I reacted with a resounding "huh, that's neat I guess." However, over the past few weeks it's come up in conversation with engineers whose opinions I respect, and so I thought it might be time to investigate it properly. After watching some of the demos, I had an "a-ha" moment, and at this point I'm pretty excited about Backstage and the idea of developer portals cutting down on the number of touch points an engineer needs to interact with to get things done.

I've tried to describe Backstage to people before, and the response is usually something along the lines of "so... like a wiki?" While Backstage does share some characteristics with a wiki, saying that it's "like a wiki" doesn't really do the idea justice. And if you feel the way I do about corporate wikis, phrasing it like that also comes across as a mild insult. In essence, Backstage tries to solve the problem of discoverability for developer resources. Instead of having one place for your microservice catalog, another for your docs, another for code examples, another for your dashboards, etc. Backstage collects all of those (and more) into a single UI. Backstage has a plugin architecture, which means that the UI for different resources (components in Backstage lingo) can be owned by separate teams; If I'm on a team that owns observability tooling, I can write a plugin to surface that information in Backstage instead of waiting on another team to do it for me.

While exciting, Backstage is still very new technology, so the docs aren't quite stable yet for onboarding new users. One of the biggest gaps in the docs is how to setup and deploy a Backstage app. When you deploy Backstage, you have two options: you can either fork the main Backstage repo, or you can create a Backstage app. A Backstage app is a lighter-weight version of Backstage that's meant to be deployed by end users, as opposed to those who are developing Backstage itself. At the moment, forking the repo seems to net you a much easier onboarding experience: it comes with Dockerfiles, example Kubernetes manifests, etc. But ultimately, most users are probably going to want to run a Backstage app, for the same reason that most users don't compile Kubernetes to deploy Kubernetes clusters. Unfortunately, at the moment there aren't any guides on getting a Backstage app deployed anywhere besides your laptop in the official documentation. So, in the spirit of too much free time on a Saturday, I decided to try to deploy a Backstage app to Kubernetes, and to write about the experience in order to give others a head start.

A caveat

This post marks a very specific point in time of a nascent technology. Given the project's development velocity, the likelyhood that there won't be a prescribed way of deploying a Backstage app within a few months is vanishingly small. More likely than not, the end user docs are going to recommend something different than what's in this post. If you're reading this a year from now, first, congrats on making it out of 2020, and second, go with what the docs say. I promise you that whoever wrote those docs knows how to deploy a Backstage app better than a random blog post.

What's not included

The Backstage app in this post is by no means meant for production use. Among other things, I've not made any attempt to secure the app, and the database runs on Kubernetes, which is what you do to databases when you hate the data they contain. A production deployment would also require a stable URL and SSL certificate, which I didn't attempt to set up for this post.

Requirements

In order to follow along with this post, you'll need these tools installed:

Create the Backstage app

The first task is to create a new Backstage app. You can do this using the npx script from the Backstage package:

npx @backstage/create-app

A prompt will first ask you to pick a name for the app, and then a database to use. I was feeling inspired, so I went with "example-app" for the name. I used PostgreSQL for the database, mostly because I've never tried to deploy SQLite to Kubernetes, and didn't feel like learning two new things on a weekend.

Set up Postgres locally

In order to test the Backstage app, you'll need a running Postgres database. Installing Postgres on your laptop is a completely fine option, but for development I like running databases in containers:

docker run -d -e POSTGRES_HOST_AUTH_METHOD=trust --net=host postgres

The --net=host flag uses the host process' networking namespace instead of creating a new one, so I don't have to worry about binding ports. Note that I'm using Fedora, and networking might work different on, say, Docker for Mac. An alternate option would be to use -p 5432:5432 to bind port 5432 from the container to your machine. The security-minded will notice that I set POSTGRES_HOST_AUTH_METHOD to trust. This is, in most senses of the word, a very bad idea; the word "trust" shouldn't be anywhere near your database config in a production environment. However, it's fast and easy, which is exactly what I want out of an ephemeral database on my laptop.

Testing the Backstage installation

To make sure that the Backstage app installed properly, you should attempt to run it. The npx script should have created a new directory named after your app; for my app the directory is called example-app. This directory should contain a packages directory, which has an app and backend directory. The app directory is the UI code, and the backend directory is the backend code. Switch to the newly-created example-app directory, and start the backend server:

# The backend app logs are pretty verbose, so we log them to a backend.log file instead of getting our terminal commands interrupted
THIS_IS_HILARIOUSLY_UNSAFE=1 POSTGRES_USER=postgres yarn workspace backend start > backend.log 2>&1 &

Then start the frontend app

yarn workspace app start

This should open a browser to localhost:3000, where you'll see the Backstage UI.

Setup the app-backend plugin

By default, Backstage's frontend and backend are served separately. This is a good choice if you're looking to be able to scale the two independently, but for simple deployments it's more complexity than one needs. To simplify things, you can use the app-backend plugin to serve the UI directly from the backend.

Installing the app-backend plugin

Start by installing the plugin:

yarn workspace backend add @backstage/plugin-app-backend

And then add your frontend as a dependency to your backend

yarn workspace backend link app

You'll then need to build the app

yarn workspace app build

Adding the plugin code to the backend

In order to use the plugin, you'll need to add a bit of extra code to packages/backend/src/index.ts

// FIXME: this code was copied from the internet, double check it

// Put this with your imports
import { createRouter } from '@backstage/plugin-app-backend'

// ...there's a bunch of lines of code here..

async function main() {
    // put this with the other calls to useHotMemoize
    const appBackend = useHotMemoize(module, () => createEnv('appbackend'));

    // put this after the apiRouter configuration
    const staticRouter = await createRouter({logger: appBackend.logger, appPackageName: 'app'});

    // Add this line to the call to createServiceBuilder
    const service = createServiceBuilder(module)
      .loadConfig(configReader)
      .addRouter('/api', apiRouter)
      .addRouter('', staticRouter) // <- this one
}

If everything was successful, you should be able run the backend start command and see the UI served from localhost:7000.

Create a kind cluster

Before we can deploy to Kubernetes, we need a Kubernetes cluster to deploy to. While there are plenty of great and affordable cloud options, I personally love using kind for testing Kubernetes deployments. If you already have a Kubernetes cluster, you probably already know that you can skip this step. If you don't already have a cluster, create one on your laptop by installing kind and running:

kind create cluster

Create a Backstage app container image

While the generated app contains a Dockerfile, it only containerizes the backend, and doesn't work with the app-backend plugin. If you want to deploy them together, you'll need to add a new Dockerfile to the root of the app directory:

# FIXME: this creates a gigantic Docker image
FROM node:12-buster

WORKDIR /usr/src/app

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production

COPY . ./
RUN yarn workspace app link
RUN yarn workspace backend link app
RUN yarn workspace backend install

CMD ["node", "packages/backend"]

Note that this Dockerfile is extremely unoptimized. On my laptop it clocked in at around a 1.3G, which is... frankly terrible. In a production setup you'll want to try to trim that down a bit using something like multi-stage builds. You'll also want to write at least a minimal .dockerignore file:

node_modules/
Dockerfile

Now you can build the Docker image

docker build . -t localhost/backstage-example:local

I avoid using the latest tag because it doesn't play well with side loading containers onto kind. Instead, I use a tag that hopefully makes it extremely clear that this is for use on my laptop.

Instead of pushing to a container registry, I side-loaded the container image onto my kind node:

kind load docker-image localhost/backstage-example:local

If this were a production deployment, you'd want to use a sensible tagging scheme, and push to a real container image registry.

Deploying Backstage to Kubernetes

Finally, we can deploy Backstage to Kubernetes. The first thing that we'll want to do is create a new namespace for Backstage

kubectl create namespace backstage

And we'll also need a password for our Postgres:

# This is literally the only attempt at security in this post
export POSTGRES_PASSWORD=yourpasswordhere

From there, we can go ahead and deploy our database. I used a pretty straightforward PG on Kubernetes setup:

cat <<EOM | kubectl apply -f -
kind: PersistentVolume
apiVersion: v1
metadata:
  name: postgres-pv-volume
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/original-source: https://severalnines.com/database-blog/using-kubernetes-deploy-postgresql
  labels:
    type: local
    app: postgres
spec:
  storageClassName: manual
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteMany
  hostPath:
    path: "/mnt/data"
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: postgres-pv-claim
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/original-source: https://severalnines.com/database-blog/using-kubernetes-deploy-postgresql
  labels:
    app: postgres
spec:
  storageClassName: manual
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 5Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: postgres-config
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/original-source: https://severalnines.com/database-blog/using-kubernetes-deploy-postgresql
    breaking.computer/bad-idea: "yes use a secret instead"
  labels:
    app: postgres
data:
  POSTGRES_USER: postgres
  POSTGRES_PASSWORD: $POSTGRES_PASSWORD
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/original-source: https://severalnines.com/database-blog/using-kubernetes-deploy-postgresql
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:10.4
          imagePullPolicy: "IfNotPresent"
          ports:
            - containerPort: 5432
          envFrom:
            - configMapRef:
                name: postgres-config
          volumeMounts:
            - mountPath: /var/lib/postgresql/data
              name: postgredb
      volumes:
        - name: postgredb
          persistentVolumeClaim:
            claimName: postgres-pv-claim
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/original-source: https://severalnines.com/database-blog/using-kubernetes-deploy-postgresql
  labels:
    app: postgres
spec:
  type: ClusterIP
  ports:
   - port: 5432
  selector:
   app: postgres

EOM

Note that if you write the manifest and apply it separately instead of using a heredoc, you'll want to find a way to interpolate the $POSTGRES_PASSWORD variable.

If any security-conscious engineers are still reading this, they'll notice that I put the Postgres password in a ConfigMap instead of a Secret. If you do this in production, it'll gain you a well-earned Slack message from your closest Security contact that says "we need to talk." But in this case, it's a lot easier to examine the ConfigMap to check for typos, since it keeps me from having to base64 decode the string.

After Postgres is deployed, we can deploy our Backstage image:

cat <<EOM | kubectl apply -f -
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backstage-backend
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/adapted-from: https://github.com/spotify/backstage/blob/master/contrib/kubernetes/plain_single_backend_deplyoment/deployment.yaml
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backstage
      component: backend
  template:
    metadata:
      labels:
        app: backstage
        component: backend
    spec:
      containers:
        - name: backend
          image: localhost/backstage-example:local
          env:
            # We set this to development to make the backend start with incomplete configuration. In a production
            # deployment you will want to make sure that you have a full configuration, and remove any plugins that
            # you are not using.
            - name: NODE_ENV
              value: development
            - name: APP_ENV
              value: development
            - name: POSTGRES_USER
              valueFrom:
                configMapKeyRef:
                  name: postgres-config
                  key: POSTGRES_USER
            - name: POSTGRES_PORT
              value: "5432"
            - name: POSTGRES_HOST
              value: postgres.backstage.svc
            - name: POSTGRES_PASSWORD
              valueFrom:
                configMapKeyRef:
                  name: postgres-config
                  key: POSTGRES_PASSWORD
          ports:
            - name: http
              containerPort: 7000
          resources:
            limits:
              cpu: 1
              memory: 0.5Gi
          readinessProbe:
            httpGet:
              port: 7000
              path: /healthcheck
          livenessProbe:
            httpGet:
              port: 7000
              path: /healthcheck
---
apiVersion: v1
kind: Service
metadata:
  name: backstage-backend
  namespace: backstage
  annotations:
    breaking.computer/copied-from-internet: "yes"
    breaking.computer/adapted-from: https://github.com/spotify/backstage/blob/master/contrib/kubernetes/plain_single_backend_deplyoment/deployment.yaml
spec:
  type: NodePort
  selector:
    app: backstage
    component: backend
  ports:
    - name: http
      port: 80
      targetPort: http
EOM

Now we should be able to run kubectl port-forward svc backstage-backend 7000:80 and see Backstage in our browser at localhost:7000.


Again, this is not a production-grade deployment. However, if you want to take this deployment and make it production grade, here are some gaps to fill in:

  • You probably want to use a database outside of Kubernetes. Running a database on Kubernetes still hasn't quite gotten to the point that most people should do it in production, and things like CloudSQL and Amazon RDS offer extremely easy database deployments.
  • You'll want to create a separate database user for Backstage. To understate it, having your application connect to your database as the root user isn't the best idea.
  • You'll need a DNS entry and an SSL certificate. Backstage requires you to configure a baseUrl, which will need to be a real DNS entry with an SSL certificate in a production deployment.
  • You'll probably want to trim down the Docker image. Because again, a 1.3 gig Docker image is going to cause headaches when your Kubernetes node is spending 5 minutes pulling the image and you're trying to autoscale a deployment.
  • You'll want observability into the system, alerting, etc. Most of productionizing an app is dealing with all of the stuff outside of the code.
Previous
Previous

Seasoning Problems

Next
Next

What I learned about writing software in music school