Backstage is an open-source framework for building internal developer portals. Its core is a software catalog that tracks who owns each service, API and piece of infrastructure, and plugins add documentation, templates and CI/CD or Kubernetes views on top of it. In this tutorial you will create a Backstage app on Ubuntu 24.04, register your own components in the catalog, and then run it in production as a Docker container backed by PostgreSQL and served over HTTPS by Nginx.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS with at least 4 GB of RAM and 2 vCPUs, for example a CubePath VPS. Installing dependencies and building the app are memory hungry, and 2 GB is not enough.
- A non-root user with
sudoprivileges. - A domain name with a DNS A record pointing to the server's public IP, used in Step 6. This guide uses
backstage.your_domain. - Basic familiarity with Git and YAML.
Step 1 - Installing Node.js, Yarn and build tools
Backstage supports the active and maintenance LTS releases of Node.js. Install Node.js 22 from the NodeSource repository, signed with its own keyring:
sudo apt update
sudo apt install -y ca-certificates curl gnupg git build-essential python3
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update
sudo apt install -y nodejs
build-essential and python3 are needed to compile native modules such as the SQLite driver used in development.
Backstage projects use Yarn 4, which is managed per project by Corepack. Enable Corepack once:
sudo corepack enable
Verify the versions:
node --version
yarn --version
v22.x.x
1.22.x
Yarn reports a 1.x version outside a project. Inside the Backstage app, Corepack switches to the Yarn version pinned in package.json automatically.
Step 2 - Creating the Backstage app
Create the app with the official scaffolder. It asks for a name, creates a directory with that name and installs all dependencies, which takes a few minutes:
cd ~
npx @backstage/create-app@latest
? Enter a name for the app [required] developer-portal
Creating the app...
...
Successfully created developer-portal
The generated project is a monorepo with two main packages:
packages/app: the React frontend.packages/backend: the Node.js backend, which also serves the built frontend in production.
The root also contains app-config.yaml (shared settings) and app-config.production.yaml (overrides used in production).
Start the app in development mode:
cd ~/developer-portal
yarn start
The frontend listens on localhost:3000 and the backend on localhost:7007. Both bind to localhost only, so to open them from your workstation, create an SSH tunnel in a second terminal on your local machine, replacing your_user and your_server_ip:
ssh -L 3000:localhost:3000 -L 7007:localhost:7007 your_user@your_server_ip
Browse to http://localhost:3000, choose Enter on the guest sign-in page, and you will see the catalog with the example entities that ship with the template. Stop the dev server with Ctrl+C when you are done.
Step 3 - Registering components in the software catalog
Everything in the catalog is described by YAML entity files, normally named catalog-info.yaml and stored in the repository of each service. Create a local file with a group, a system and a component to see how entities relate to each other:
mkdir -p ~/developer-portal/catalog
nano ~/developer-portal/catalog/payments.yaml
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: payments-team
spec:
type: team
children: []
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: ecommerce
description: Online store and checkout
spec:
owner: payments-team
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
description: Handles card payments and refunds
tags:
- python
- payments
links:
- url: https://status.your_domain
title: Status page
spec:
type: service
lifecycle: production
owner: payments-team
system: ecommerce
Tell Backstage to load the file. Open app-config.yaml:
nano ~/developer-portal/app-config.yaml
Find the catalog.locations list and add an entry for your file. Paths are relative to packages/backend, which is why they start with ../../:
catalog:
rules:
- allow: [Component, System, API, Resource, Location]
locations:
- type: file
target: ../../examples/entities.yaml
- type: file
target: ../../catalog/payments.yaml
rules:
- allow: [Group, System, Component]
Keep the other locations the template already defines. Run yarn start again, open the catalog and select payment-service. The page shows its owner, system, tags and link, and the Dependencies tab shows its relation to the ecommerce system.
In a real setup you would commit a catalog-info.yaml to each repository and register it by URL, either with Create > Register Existing Component in the UI or with a type: url location. Reading from GitHub requires a token in the integrations.github section of app-config.yaml; the template already contains that section, reading the token from the GITHUB_TOKEN environment variable.
Step 4 - Setting up PostgreSQL
The development setup keeps its data in an in-memory SQLite database that is lost on every restart. Production needs PostgreSQL. Install it from the Ubuntu repositories:
sudo apt install -y postgresql
Create a role for Backstage. It needs the CREATEDB privilege because each backend plugin creates its own database, such as backstage_plugin_catalog. Replace your_strong_password with a real password:
sudo -u postgres psql -c "CREATE ROLE backstage WITH LOGIN CREATEDB PASSWORD 'your_strong_password';"
Test the login over TCP, which is how Backstage will connect:
psql -h 127.0.0.1 -U backstage -d postgres -c "SELECT current_user;"
current_user
--------------
backstage
(1 row)
The template's app-config.production.yaml already configures the pg client and reads the connection details from the POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER and POSTGRES_PASSWORD environment variables, so no file changes are needed for the database.
Step 5 - Building the production Docker image
In production, the backend serves the compiled frontend and everything runs as a single Node.js process on port 7007. The supported way to package it is the Dockerfile that ships in packages/backend.
Install Docker and the Buildx plugin, which the Dockerfile needs for its cache mounts, and add your user to the docker group:
sudo apt install -y docker.io docker-buildx
sudo usermod -aG docker $USER
newgrp docker
Before building, set the public URL of the portal. Open the production config:
nano ~/developer-portal/app-config.production.yaml
Set both base URLs to your domain, keep the existing database block, and point the catalog at your entity file. Lists in a production config replace the ones in app-config.yaml, and inside the container the app runs from /app, so the path starts with ./:
app:
baseUrl: https://backstage.your_domain
backend:
baseUrl: https://backstage.your_domain
listen:
port: 7007
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
catalog:
locations:
- type: file
target: ./catalog/payments.yaml
rules:
- allow: [Group, System, Component]
The image must contain that file. Open the Dockerfile:
nano ~/developer-portal/packages/backend/Dockerfile
Find the line that copies the app-config files into the image and add this line right after it:
COPY --chown=node:node catalog ./catalog
Build the app on the host, then the image. The --config flags let the frontend build pick up the production app.baseUrl:
cd ~/developer-portal
yarn install --immutable
yarn tsc
yarn build:backend --config ../../app-config.yaml --config ../../app-config.production.yaml
docker image build . -f packages/backend/Dockerfile --tag backstage
Confirm the image exists:
docker image ls backstage
REPOSITORY TAG IMAGE ID CREATED SIZE
backstage latest 3c1f0a2b9d8e 30 seconds ago ...
Store the database settings in an environment file readable only by you:
nano ~/developer-portal/.env.production
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=backstage
POSTGRES_PASSWORD=your_strong_password
chmod 600 ~/developer-portal/.env.production
Run the container on the host network so it can reach PostgreSQL on 127.0.0.1, and let Docker restart it after reboots:
docker run -d --name backstage --restart unless-stopped \
--network host \
--env-file ~/developer-portal/.env.production \
backstage
Follow the logs until the backend reports that it is listening:
docker logs -f backstage
Look for a log line containing Listening on :7007, then press Ctrl+C to stop following the logs. Check that it answers locally:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7007
200
Port 7007 is not opened in the firewall; Nginx will be the only public entry point.
Step 6 - Publishing Backstage with Nginx and HTTPS
Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginx
Create a server block for the portal:
sudo nano /etc/nginx/sites-available/backstage
server {
listen 80;
server_name backstage.your_domain;
location / {
proxy_pass http://127.0.0.1:7007;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Enable it, test the configuration and open the web ports:
sudo ln -s /etc/nginx/sites-available/backstage /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Request a Let's Encrypt certificate. Certbot edits the server block to add HTTPS and a redirect from HTTP:
sudo certbot --nginx -d backstage.your_domain
Open https://backstage.your_domain in your browser. The portal loads over HTTPS with the catalog you registered.
ImportantThe guest sign-in provider only works in development. Before your team uses the portal, configure a real authentication provider (GitHub, GitLab, Google, Microsoft Entra ID or any OIDC provider) in the
authsection ofapp-config.production.yamland rebuild the image. Until then, treat the instance as a private test.
Updating the deployment
Every change to the configuration, the catalog files or the plugins requires a new image. The update cycle is:
cd ~/developer-portal
yarn install --immutable
yarn tsc
yarn build:backend --config ../../app-config.yaml --config ../../app-config.production.yaml
docker image build . -f packages/backend/Dockerfile --tag backstage
docker rm -f backstage
docker run -d --name backstage --restart unless-stopped --network host --env-file ~/developer-portal/.env.production backstage
To upgrade Backstage itself, run yarn backstage-cli versions:bump in the project, review the changes, and rebuild.
Troubleshooting
yarn install or the build is killed. The process ran out of memory. Add swap or use a server with more RAM; 4 GB is the practical minimum.
The container exits with a database error. Read docker logs backstage. password authentication failed points to a wrong value in .env.production. permission denied to create database means the role was created without CREATEDB; fix it with sudo -u postgres psql -c "ALTER ROLE backstage CREATEDB;".
The page loads but API calls fail. Both app.baseUrl and backend.baseUrl must match the public URL exactly, including https://. If you change them, rebuild the image, because the frontend embeds app.baseUrl at build time.
A catalog entity does not appear. Search the backend logs for the file name with docker logs backstage 2>&1 | grep payments.yaml. Most failures are YAML errors, a file that was not copied into the image, or an entity kind that is not allowed by the rules of its location.
Conclusion
You created a Backstage app on Ubuntu 24.04, registered your own group, system and component in the software catalog, and deployed the portal in production with PostgreSQL, Docker and Nginx over HTTPS. Next, configure an authentication provider for your organization, move catalog-info.yaml files into your service repositories with GitHub discovery, and add TechDocs so each service's Markdown documentation appears next to its catalog entry.
