Module 09 · Ship it · 60 min · You are: Platform team
Ship it: an image, production config and an upgrade
In this module
- You learn: How the backend serves the built frontend, what goes into the image, how production config layers on top, and how upgrades work.
- You do: Change the Dockerfile, write
app-config.production.yaml, build and run the image next to Postgres, then bump Backstage to 1.56.3 and rebuild. - You end with: A
backstageimage that runs the whole portal on one port, with your catalog, template and docs, on Backstage 1.56.3.
yarn start is two dev servers and hot reload. Production is one Node process that serves the built frontend and the API, configured by environment variables. Anything the portal reads from disk (catalog files, templates) must be in the image, and anything it runs (MkDocs) too. Upgrades are part of shipping: Backstage releases monthly, and small steps are cheap.
Open this module in the interactive course to track progress.
Concepts
- Host build
yarn build:backendbuilds the frontend and backend on your machine; the Dockerfile only copies the result in.- Config layering
- The container starts with
--config app-config.yaml --config app-config.production.yaml. Objects merge; lists, likecatalog.locations, are replaced. - Release
- A Backstage release is a set of package versions.
versions:bump --releasemoves every@backstage/*package to it together.
Files to know
Open them in your editor or on GitHub; the bullets say what to look at.
portal/packages/backend/Dockerfile(reference, your repo): How the image is built.portal/app-config.production.yaml(reference, your repo): Only what differs in production.
Steps
1. Put MkDocs and your files in the image
Stop the dev portal (Ctrl+C): the container uses port 7007 too.
Change in ~/work/portal/packages/backend/Dockerfile
- apt-get install -y --no-install-recommends libsqlite3-dev && \
- rm -rf /var/lib/apt/lists/*
+ apt-get install -y --no-install-recommends libsqlite3-dev python3 python3-venv && \
+ rm -rf /var/lib/apt/lists/*
+
+ # TechDocs builds docs with MkDocs inside the container (runIn: local).
+ RUN python3 -m venv /opt/mkdocs && \
+ /opt/mkdocs/bin/pip install --no-cache-dir mkdocs-techdocs-core==1.7.1
+ ENV PATH="/opt/mkdocs/bin:$PATH"
Change in ~/work/portal/packages/backend/Dockerfile
- # This will include the examples, if you don't need these simply remove this line
- COPY --chown=node:node examples ./examples
+ COPY --chown=node:node catalog ./catalog
+ COPY --chown=node:node templates ./templates
Nothing reads the example data any more:
cd ~/work/portal && git rm -rq examples
2. Write the production config
Replace the whole file ~/work/portal/app-config.production.yaml
# Loaded on top of app-config.yaml inside the container (see the Dockerfile CMD).
# Only what differs from your laptop goes here.
app:
# The backend serves the frontend in production, so both use one URL.
baseUrl: http://localhost:7007
backend:
baseUrl: http://localhost:7007
listen: ':7007'
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
auth:
environment: production
providers:
github:
production:
clientId: ${AUTH_GITHUB_CLIENT_ID}
clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}
signIn:
resolvers:
- resolver: usernameMatchingUserEntityName
techdocs:
generator:
# No Docker inside the container: mkdocs is installed in the image instead.
runIn: local
catalog:
# Lists replace, they do not merge, so every location is repeated here with
# paths relative to /app in the image.
locations:
- type: file
target: ./catalog/org.yaml
rules:
- allow: [Group, User]
- type: file
target: ./catalog/systems.yaml
rules:
- allow: [Domain, System]
- type: file
target: ./templates/node-service/template.yaml
rules:
- allow: [Template]
3. Build
Give Docker at least 4 GB of memory: the build runs out with 2 GB.
cd ~/work/portal && yarn install --immutable && yarn tsc && yarn build:backend
Build the image. It is tagged backstage:
yarn build-image
4. Run it next to Postgres
A fresh database on its own network, so you see only what is in the image and in Git.
docker network create portal-net && docker run -d --name portal-prod-db --network portal-net -e POSTGRES_PASSWORD=portal-prod postgres:17.11
cd ~/work/portal && set -a && . ./.env && set +a
Start the portal in the foreground. The variables without = are passed from your shell:
docker run --rm --name portal --network portal-net -p 7007:7007 \
-e POSTGRES_HOST=portal-prod-db -e POSTGRES_PORT=5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=portal-prod \
-e PORTAL_API_TOKEN -e GITHUB_TOKEN -e AUTH_GITHUB_CLIENT_ID -e AUTH_GITHUB_CLIENT_SECRET \
backstage
A warning that backend.baseUrl points at localhost in production is expected here.
1. Open http://localhost:7007 (one port now) and sign in with GitHub.
2. The catalog has your teams, checkout, cart and portal, and the template.
3. checkout → TechDocs builds with MkDocs inside the container.
In your second terminal (with .env loaded). Expect cart, checkout and portal:
curl -s -H "Authorization: Bearer $PORTAL_API_TOKEN" "localhost:7007/api/catalog/entities?filter=kind=component" | jq -r '.[].metadata.name'
5. Upgrade Backstage
Stop the container (Ctrl+C). Bump every Backstage package to one release, then build and test as before.
cd ~/work/portal && yarn backstage-cli versions:bump --release 1.56.3
Expect 1.56.3:
jq -r .version backstage.json
The upgrade helper (link above) shows changes to files create-app generated, such as the Dockerfile; apply any that matter. Then build, test and run the image again:
yarn install && yarn tsc && yarn build:backend && yarn build-image
6. Ship it
cd ~/work/portal && git status --short
Propose it as a pull request. Put it on its own branch and push it:
git switch -c ship-image && git add -A && git commit -qm "Ship: production image and Backstage 1.56.3"
git push -qu origin HEAD
Open the pull request. It prints a link:
gh pr create --fill
Open that link in your browser. Files changed shows exactly your diff.
Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:
gh pr merge --squash --delete-branch
Either way, bring your copy up to date:
git switch -q main && git pull -q
Simplified for the course
The image runs on your laptop with
localhostURLs. In production it runs on Kubernetes (or a container service) behind TLS with a real hostname, Postgres is managed, secrets come from a secret store, and CI builds and pushes the image on every merge. The Argo CD course shows how to deploy it from Git.
Break it on purpose: A slimmer image
Someone trims the Dockerfile and removes a COPY line that "only copies YAML". The build passes and the container starts.
You practise: reading a production startup, and connecting a sign-in failure to a file missing from the image.
1. Break it
Delete from ~/work/portal/packages/backend/Dockerfile
- COPY --chown=node:node catalog ./catalog
cd ~/work/portal && yarn build-image
Start with a fresh database, like a new environment would:
docker rm -f portal-prod-db && docker run -d --name portal-prod-db --network portal-net -e POSTGRES_PASSWORD=portal-prod postgres:17.11
cd ~/work/portal && set -a && . ./.env && set +a
docker run --rm --name portal --network portal-net -p 7007:7007 \
-e POSTGRES_HOST=portal-prod-db -e POSTGRES_PORT=5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=portal-prod \
-e PORTAL_API_TOKEN -e GITHUB_TOKEN -e AUTH_GITHUB_CLIENT_ID -e AUTH_GITHUB_CLIENT_SECRET \
backstage
2. What you see
Expected: you sign in. You get: Failed to sign-in, unable to resolve user identity. The catalog has checkout and cart (from discovery) but no teams.
3. Fix it yourself
Find out why the teams are missing in this environment, fix the image, and prove the files are inside it.
Hint 1
Search the container log for org.yaml.
Hint 2
The location is ./catalog/org.yaml, relative to /app in the container. What does the Dockerfile copy into /app?
The fix
The log says file /app/catalog/org.yaml does not exist. Put the line back:
Add to ~/work/portal/packages/backend/Dockerfile before COPY --chown=node:node templates ./templates
COPY --chown=node:node catalog ./catalog
cd ~/work/portal && yarn build-image
Start the container again (same docker run). In the second terminal, expect org.yaml and systems.yaml:
docker exec portal ls /app/catalog
After you fixed it: the five questions
- What failed?
- The image had no
catalog/folder, so the platform's teams and users never loaded and sign-in found no User. - How was it detected?
- By sign-in failing, and a warning in the startup log. An existing database would have hidden it: entities stay in Postgres.
- Who responds?
- The platform team: they own the image.
- How is it corrected?
- Copy the folder into the image again and redeploy.
- How is it prevented?
- A smoke test in CI that starts the image with an empty database and checks the catalog has Users and Groups.
Quiz
- You list
catalog.locationsinapp-config.production.yaml. What happens to the ones inapp-config.yaml?- They are replaced: the production list is the only list
- Both lists are merged
- The base file wins for lists
- Why did the broken image work fine against the old database?
- Postgres caches files from the image
- Entities stay in the database; nothing deleted them, so only a fresh database showed the gap
- The location warning is only shown with a fresh database
- How should you upgrade Backstage?
- Edit each
@backstage/*version by hand - Recreate the app with the newest create-app
- Run
versions:bumpregularly, check the upgrade helper, then build and test
- Edit each
Answers
- They are replaced: the production list is the only list Lists replace. That is why the production file repeats every location.
- Entities stay in the database; nothing deleted them, so only a fresh database showed the gap Test images against an empty database.
- Run
versions:bumpregularly, check the upgrade helper, then build and test Small, frequent steps through the release are cheap; big jumps are not.
Field note
The best Backstage teams I know upgrade every month on a fixed day. The worst ones are a year behind and need a project to catch up.