What yarn tsc Produces, and Why the Dockerfile Extracts Two Tarballs
In one line
You create a Backstage app with npx @backstage/create-app@latest and bring up the frontend (3000) and backend (7007) together with yarn start. yarn tsc type-checks the whole repository as a single compilation unit and leaves the result in dist-types/, and yarn build:backend creates two archives, skeleton.tar.gz and bundle.tar.gz, in packages/backend/dist/. A Docker image unpacks these two in order so that the dependency installation is cached. You register a theme in createApp in packages/app/src/App.tsx with themes, put a plugin's React components in plugins/<id>/src/components/, and plugin.ts exports them as an extension. The sources are the documents Getting started, Build system, Building a Docker image, Customizing the UI, and Plugin structure.
Why this was needed
Backstage is a framework, not a product, so the app repository tailored to your organization is itself the deliverable. That repository is a monorepo bundled as a Yarn workspace, and the frontend, backend, and plugins are each packages. Because of this structure, "build" is not one thing — type checking, package builds, the frontend bundle, the backend bundle, and the container image each have different tools and outputs. The reason the exam groups this workflow into one domain (24%) is that if you do not know which step produces what and which step consumes it, you can read neither a CI pipeline nor a Dockerfile.
How it works
Create and run
npx @backstage/create-app@latest asks for the app name, generates files in a directory with that name, and then even runs yarn install and yarn tsc. The skeleton of what it generates looks like this.
app
├── app-config.yaml # 앱 설정
├── catalog-info.yaml # 카탈로그 엔티티 기술자
├── package.json # 루트. 여기에 npm 의존성을 넣지 말 것
└── packages
├── app # 프론트엔드 앱
└── backend # 백엔드
yarn start brings up the frontend and backend as two processes, [0] and [1], in one window, and when you see "Rspack compiled successfully" you can view the app at http://localhost:3000. If the system is isolated, you must open ports 3000 and 7007. This standalone installation is for evaluation, using in-memory SQLite and demo data, and is not for production. The requirements are Node.js Active LTS (the documentation recommends 22 or 24), Yarn 4.4.1 (yarn set version 4.4.1 after corepack enable), 20GB of disk, and 6GB of memory.
Type checking — the whole repository is one unit
The feature the build system documentation emphasizes most is that the whole project is a single TypeScript compilation unit. That is because splitting it per package would complicate the configuration and make full type checking several times slower. So each package's entry point points to the TypeScript source. Locally, incremental checking is the default and the results accumulate in dist-types/ at the repository root. It also gains speed by skipping type checks of libraries inside node_modules, and in CI the documentation recommends yarn tsc:full, which turns off these two optimizations. dist-types/ is not just a cache — this folder is the entry point for the type declaration files that package build produces, so before building a package that has type declarations, you must run type checking first.
Three kinds of builds and their outputs
| Command | Tool | Output | Target |
|---|---|---|---|
backstage-cli package build |
Rollup | CJS, ESM, and type declarations in the package's dist/ |
Packages other than the frontend and backend roles (plugins and libraries) |
| Frontend bundle | Webpack (per the documentation; the startup log shows Rspack) | Plain assets in dist/ (short cache) + hashed assets in dist/static/ (long cache) |
packages/app |
yarn build:backend / backend:bundle |
Its own collection | packages/backend/dist/bundle.tar.gz + skeleton.tar.gz |
packages/backend |
The backend bundle does not use Webpack. It gathers the backend package and its local dependencies in the same directory layout as the monorepo and bundles them into bundle.tar.gz, including the root package.json and yarn.lock. The skeleton.tar.gz next to it has the same layout but contains only the package.json files. The reason for splitting into these two is the key to reading a Dockerfile — yarn install can be done with the skeleton alone, so if the dependencies stay the same even when the source changes, the installation layer is cached. The backend packages must already be built before creating the bundle, and if you give the --build-dependencies flag, the bundle command builds them for you.
Docker image — host build and multi-stage
The Docker documentation separates two methods and recommends the first.
Host build: You do most of the build outside Docker (on the host or in CI). The order is yarn install --immutable → yarn tsc → yarn build:backend, and then you create the image with packages/backend/Dockerfile. This Dockerfile must be run with the repository root as the build context so that it can reach the root's yarn.lock and package.json.
docker image build . -f packages/backend/Dockerfile --tag backstage
docker run -it -p 7007:7007 backstage
The flow of the Dockerfile that create-app provides is this. On top of node:24-trixie-slim, it drops to USER node, copies .yarn, .yarnrc.yml, and backstage.json, copies and unpacks yarn.lock, package.json, and skeleton.tar.gz, installs only production dependencies with yarn workspaces focus --all --production, and finally copies and unpacks bundle.tar.gz and app-config*.yaml. The start command is node packages/backend --config app-config.yaml --config app-config.production.yaml. The .dockerignore that is generated with it reduces the context by excluding packages/*/src, plugins, node_modules, and *.local.yaml — because the approach puts in build outputs, not source. The documentation warns that the host's Node version must be the same as the base image's so that native modules do not break at runtime.
Multi-stage build: You do the entire build inside Docker. It is usually slower, but you use it when the build environment needs a build inside Docker or has other constraints. It splits into three stages — stage 1 leaves only package.json with find to create the skeleton layer for the yarn install cache, stage 2 does the same work as the host build with yarn install --immutable → yarn tsc → yarn --cwd packages/backend build and then unpacks the two archives, and stage 3 creates the final image. The .dockerignore for this method needs access to the source, so unlike the host build's, it excludes only outputs such as dist-types, node_modules, and packages/*/dist.
Both methods have prerequisites — the default Guest authentication provider is not meant for container environments, so you must set up an authentication provider first and prepare Postgres. To serve the frontend separately you must remove @backstage/plugin-app-backend from the backend, but then the backend loses the feature of injecting frontend configuration. There is also advice to try adding --progress=plain and --no-cache when the build looks odd.
Themes — two systems, Material UI and Backstage UI
The UI customization documentation explains that two UI systems now coexist in Backstage. The original Material UI (MUI) is a JS-based theme, is applied with UnifiedThemeProvider, and is used by most existing plugins. The new Backstage UI (BUI) is based on CSS variables and tokens, and its class names start with bui-. Which one to change is decided by looking at the component's class name.
There is one place to register — you give a themes array to createApp in packages/app/src/App.tsx. Each item has id, the title shown on the settings screen, variant which is light or dark (put on the body as a data-theme-mode attribute), icon, and a Provider for MUI. This array replaces the default themes, so you must include both light and dark, and if you need the defaults you can use themes.light and themes.dark from @backstage/theme.
import { createBaseThemeOptions, createUnifiedTheme, palettes } from '@backstage/theme';
export const lightTheme = createUnifiedTheme({
...createBaseThemeOptions({ palette: palettes.light }),
fontFamily: 'Comic Sans MS',
defaultPageTheme: 'home',
});
An MUI theme is made by spreading createBaseThemeOptions({ palette }) into createUnifiedTheme. You spread palettes.light and then override values such as primary.main and navigation.background, set the page header color and shape in pageTheme with genPageTheme({ colors, shape: shapes.wave }), and partially redefine typography by spreading defaultTypography and changing only h1. For a custom font, put @font-face in styleOverrides of MuiCssBaseline in components. On the BUI side, you import packages/app/src/styles.css in App.tsx and override variables such as --bui-bg-app and --bui-fg-primary under :root and [data-theme-mode='light'] and [data-theme-mode='dark'].
Where do React components go in a plugin?
If you choose frontend-plugin in yarn new, a plugin package is created and automatically connected to the app — the dependency in app/package.json and the import in app/src/App.tsx are added together, so if the app is running you can see it right away at http://localhost:3000/my-plugin. A plugin is a separate package with package.json and src/, so it can be published to npm, and you can also run it alone without starting the whole app, using the configuration in the dev/ directory.
plugins/my-plugin/
dev/index.ts # 플러그인만 따로 띄우는 설정
src/
components/ExampleComponent/ # 페이지 컴포넌트 (React)
components/ExampleFetchComponent/ # 외부 API 를 부르고 MUI 표로 그림
plugin.ts # createPlugin + createRoutableExtension
routes.ts # rootRouteRef
index.ts # 폴더 단위 export
plugin.ts is the core of the wiring. You create the plugin with createPlugin({ id, routes: { root: rootRouteRef } }) and export a createRoutableExtension({ name, component: () => import('./components/ExampleComponent').then(m => m.ExampleComponent), mountPoint: rootRouteRef }) wrapped in plugin.provide(). The app imports this extension and attaches it to a route. That is, you change React components inside src/components/, and to expose a new page in the app you must export it as an extension in plugin.ts. This document is based on the legacy frontend system, and it carries a note that in the new frontend system the wiring in plugin.ts is quite different.
What it looks like in the field
There was a team whose image build in CI failed with "packages/backend/dist/skeleton.tar.gz not found." The Dockerfile was being run with packages/backend/ as the context, and yarn build:backend before it was also missing. A host build puts outputs made on the host into the image, so the build order and the context root are the contract.
Another team had yarn tsc passing locally while only CI failed. Locally incremental checking and skipping library types were on, and CI used tsc:full. That difference is exactly why the documentation recommends tsc:full for CI.
What to check in the next quiz
The quiz asks about what yarn start brings up and its ports, the output of yarn tsc and why dist-types/ is needed, the difference between skeleton.tar.gz and bundle.tar.gz, the difference between host build and multi-stage build, the themes item of createApp, and the role of plugin.ts.