How to build a production Docker image for Backstage
A platform team builds a production Docker image for Backstage by compiling the frontend and backend bundles in a controlled build stage, copying only the runtime output into a slim Node.js image, and passing environment-specific configuration at deploy time rather than baking it in. The image should be built the same way every time, because Backstage gives you no packaged container from the project itself. Backstage is an open source framework released under the Apache License 2.0 (SPDX: Apache-2.0), and the project offers no paid edition. Your team owns the image, its base, its patching and its build pipeline from the first day.
The image contains the built frontend static assets, the compiled backend bundle, the production runtime dependencies and the app-config files you choose to ship. Decide early whether app-config.yaml holds only non-sensitive defaults, and whether a app-config.production.yaml file is copied in or mounted from a ConfigMap or Secret at runtime. Anything copied into an image layer stays in that layer's history, so the build must never receive tokens, database passwords or signing keys as build arguments or COPY sources. Use runtime environment variables or a secret store for those values and confirm the exact configuration loading behavior for your Backstage version in the upstream documentation.
The Backstage release and versioning policy ships a main release line monthly and a next release line weekly, and it backports high and critical vulnerabilities to recent releases. That cadence means you will rebuild the image often, sometimes for a security fix rather than a feature. Pin the Node.js version in the build stage to one of the two even-numbered releases the policy supports, match the base image to that same major version, and record the Backstage version and base image digest in each build. Confirm the supported Node.js pair for your target release before you choose a base image, since the policy names the versions per release.
Host build versus multi-stage build
A host build runs yarn install and yarn build on a machine outside the container, then copies the resulting bundles and packages into a slim runtime image. The Dockerfile stays short because it only installs the runtime dependencies and sets the start command. The approach is fast to iterate on, but the output depends on whether the host matches what the image expects, so it works best when one controlled CI runner produces every artifact.
- Node.js version drift: the compiled output reflects whatever Node.js version the builder had installed, which can differ from the runtime base image.
- Native modules: packages with native bindings compiled on macOS or Windows can fail or misbehave in a Linux container.
- Build tools in the source tree: the host copy is usually clean, but the step is easy to skip, so confirm the context excludes local
node_modulesand.envfiles. - Cache behavior: caching depends on the CI runner's dependency cache, not on Docker layers, so rebuilds from scratch are common.
- Audit trail: the image does not show how its artifacts were produced, which makes evidence collection harder.
Before accepting a host build, confirm that the CI runner pins the Node.js version used in the base image and that the artifacts are produced on a single, documented pipeline.
A multi-stage Dockerfile runs the install and build steps inside a builder stage, then copies only the built output into a separate runtime stage. Every developer and CI job produces the same artifacts from the same Dockerfile, so the image does not depend on the local toolchain. The cost is a longer build and a Dockerfile that needs careful layer ordering to stay fast.
- Reproducibility: the Node.js version, system libraries and native build tools are fixed by the builder image, so laptops and CI produce matching results.
- Smaller final image: compilers, headers and dev dependencies stay in the builder stage and never reach the runtime layers.
- Layer caching: copy
package.jsonand the lockfile before the source tree so dependency installation is reused when only code changes. - Verifiable inputs: the build reads the lockfile and pinned base image, which gives auditors a clear recipe to review.
- Slower first build: the builder stage reinstalls everything on a cold cache, so plan for a longer initial run in CI.
Confirm that the builder and runtime stages use the same Node.js major version and that the final stage runs as a non-root user with only the files it needs.
For a production Backstage image, the multi-stage build is the default. It removes the dependence on each engineer's machine, keeps build tools out of the runtime image, and makes the image definition itself reviewable. Teams that already have a hardened CI pipeline can still use it, because the Dockerfile becomes the single source of truth for what runs in production.
- Single source of truth: one Dockerfile defines the build for laptops, pull requests and release jobs.
- Clear separation: builder and runtime stages have different purposes, which simplifies scanning and patching.
- Easier rollback: each image tag maps to a specific lockfile, base image and source revision.
- Pinned inputs: use a fixed Node.js base image tag and a committed lockfile so rebuilds do not drift.
- Compatibility checks: the Node.js version must match the releases Backstage supports, which the versioning policy lists by release line.
Before adopting the default, check the Backstage versioning policy for the Node.js versions supported by your release line and align the builder image with them.
A host build is acceptable in narrower situations, usually when the team needs quick iteration in a sandbox or when a single pipeline already produces verified artifacts. It can also suit air-gapped environments where the build machine is the only place with approved dependencies and the image is assembled from pre-built, scanned bundles. In these cases the controls shift from the Dockerfile to the build host and its change records.
- Sandbox or proof of concept: short-lived environments where speed matters more than rebuild consistency.
- Locked-down build hosts: an approved runner with pinned Node.js, dependency mirrors and logged builds.
- Air-gapped assembly: the bundle is built inside the enclave and only the runtime image is assembled there.
- Existing pipeline investment: teams with mature artifact promotion may keep a host build and add provenance records.
- Temporary workaround: a short-term choice that should be replaced once the Dockerfile is ready.
If you use a host build, confirm the Node.js version, the lockfile hash and the source revision are recorded with every image tag so the output can be reproduced later.
Matching the Node.js version to the base image
Backstage supports only two Node.js major versions at a time, so the base image must fall inside that pair. The cards cover the supported set, how to pin the image tag, how to keep local tooling in step, and how to recheck the policy at each upgrade.
The supported Node.js pair
Backstage supports two adjacent even-numbered Node.js releases. As of Backstage 1.46.0, the versioning policy names Node.js 22 and 24. Any builder image outside that pair produces a build your team cannot get support for, and odd-numbered releases are not part of the policy. Before you choose a major, open the policy for the Backstage release you plan to deploy and record the pair it names.
Pinning the base image tag
Pin the base image to a supported major, such as node:22 or node:24, in the FROM line of the Dockerfile. Avoid floating tags like latest, because a silent base image change can move the runtime to a major outside the supported pair between two builds. Choose the Debian or Alpine variant deliberately, since native modules compiled during the build must match the runtime libc of the final stage.
Aligning local tooling
Developers should run the same Node.js major that the image uses. Record it in a version manager file such as .nvmrc and in the engines field of the root package.json so that a mismatch fails early. If a developer builds on a different major, yarn may resolve dependencies differently from the container build, and the bug appears only in CI or production. Check what your generated app already declares before adding anything.
Rechecking the policy on upgrades
Run the Node.js check in the same change as yarn backstage-cli versions:bump, not in a later cleanup. Backstage ships a main release line monthly and a next release line weekly, so the supported pair can change between your upgrades. Review the Upgrade Helper output for the target version, update the FROM line, and rebuild the image in the same pull request so the code and runtime move together.
Handling a dropped Node.js major
When a Node.js major leaves the supported pair, plan the move before the next scheduled release, not after a vulnerability notice. The policy says high and critical severity fixes are backported to releases from the last 6 months where feasible, which depends on your running version staying close to current. Track the Node.js release dates against your Backstage upgrade calendar, and budget a rebuild for each image that still points to the retired major.
Confirming the target release
Confirm the supported Node.js set against the policy for the exact Backstage release you are deploying, because this page is not the source of truth. The GitHub releases page lists v1.55.3, published September 29, 2026, as the latest release at the time of research, and the next release line is 1.56.0. Ask your implementation partner to show the policy line, the image tag and the build log in the handover record.
Which app-config files go into the image
Bake only the base configuration into the image: the settings that are identical in every environment, such as plugin wiring, feature flags that do not change between stages, and the catalog and template locations that every instance should load. Everything that differs between development, staging and production, or that is a secret, belongs outside the image. That includes the database host and credentials, the sign-in client secret, integration tokens, the external URL of the portal and any cache endpoint. Backstage reads these through environment variables referenced from the config files, so the same image can run everywhere and the values arrive at container start. Keep the file list short and deliberate, because every file you copy is a file someone can read from the image later.
Backstage merges several app-config*.yaml files in the order they are passed on the command line, with later files overriding earlier ones. A common pattern is an app-config.yaml holding the defaults, an app-config.production.yaml loaded when the process runs in production, and optional overrides supplied at runtime. Confirm the exact startup flags and file names against the configuration documentation for your Backstage version before you write the container command. The production file must not inherit development defaults: a backend.database block that points at SQLite, or a guest sign-in provider, will ship to production silently if it is left in the base file. Move those settings into development-only files and check the final merged configuration in a staging container before release.
Keep one image for all environments and promote it unchanged from test to production, so the artifact you tested is the artifact you run. Frontend configuration deserves a specific check. In the default setup the backend injects the frontend configuration at runtime, so values set through the same layered files reach the browser without a rebuild. If you later serve the frontend separately from NGINX or a CDN, that injection stops and the static bundle must carry or load its own configuration, which changes what belongs in the image. Ask your platform team to confirm which values the browser needs and where they will come from before you decide what to copy.
Setting up the image build step by step
This sequence turns a Backstage repository into a small, non-root container image that starts from the same dependency set your team tested locally. Each step ends with a check so problems surface in the build, not in production.
Pin the Node.js base image
Choose a Node.js base image whose major version matches the Node.js release your Backstage version supports. The Backstage versioning policy supports two adjacent even-numbered Node.js releases, so confirm which pair your release line targets before you write the first line of the Dockerfile. Pin the image by a specific version tag rather than a floating tag such as latest. Check: run node --version inside the base image and compare it with the version your local build uses.
Install dependencies from the lockfile
Copy the root package.json, yarn.lock and the package.json files for every workspace into the build stage before copying source code. Run an immutable install so Yarn fails if the lockfile would change. Because Docker caches each layer, this ordering means source edits do not force a full dependency download. Check: the install finishes without rewriting yarn.lock, and git status in the build context shows no lockfile diff.
Build the packages
Copy the rest of the monorepo source into the build stage and run the TypeScript and package build for the backend and any frontend bundle your deployment serves. Keep this step in the build stage only, so compilers and dev dependencies never reach the runtime image. Check: the build exits with status zero and the expected output directories, such as dist folders for each package, exist.
Produce the production bundle
Run the Backstage CLI packaging command that creates the deployable backend bundle, then install production-only dependencies from the lockfile. This produces a self-contained archive and a trimmed node_modules tree. Confirm that the bundle includes the backend entry point and that devDependencies are absent from the output. Check: the bundle directory contains the backend entry point and running a production install reports no dev packages.
Copy only runtime artifacts into the final stage
Start a second stage from the same pinned base image and copy in the packed bundle, the production node_modules and the app-config files the process needs at runtime. Leave out source trees, test fixtures, .git directories and any local .env files. Secrets should arrive at runtime through environment variables or mounted files, not in these copies. Check: list the final image layers and confirm the image size dropped compared with the build stage, and that no .env or .git paths appear in the filesystem.
Set a non-root user and exposed port
Create or select an unprivileged user in the final stage, set file ownership on the application directory to that user, and switch to it with USER before the start command. Declare the backend port with EXPOSE so operators know which port to map. Check: run docker run with a shell command that prints the effective user ID and confirm it is not zero.
Define the start command
Set the container entry point to run the packaged backend with Node.js, passing the production config path explicitly. Avoid running yarn or the CLI dev server in production. Make sure the process listens on the port your health probes will target. Check: start the container against a test PostgreSQL database supplied through environment variables, then request the backend health endpoint and confirm it responds successfully.
Verify the image end to end
Run the finished image against a test database with the same environment variables your staging cluster will use, then confirm the catalog loads and the health endpoint responds. Scan the image with the tooling your security team already uses and record the Node.js and Backstage versions in the image metadata. Check: the container starts cleanly, the health check passes after a restart, and the scan report and version labels are saved with the build artifacts.
Keeping secrets out of image layers
Keep credentials out of the Backstage image entirely. Docker stores every instruction that changes the filesystem as a layer, and docker history exposes the arguments used to build each one. A token passed with ARG or ENV during the build, or a .env or app-config.local.yaml file copied in with COPY . . and deleted in a later RUN rm step, still exists in an earlier layer. Anyone who can pull the image can recover it, so deleting the file afterward does not remove the secret. Treat any value that reached a build step as exposed and rotate it.
The correct pattern splits secrets from the artifact. Config that is safe to ship, such as the base URL, plugin switches and the catalog locations, belongs in the image or a mounted app-config.production.yaml. Tokens for GitHub, GitLab, the database and the auth provider should be referenced by environment variable name in config (for example ${POSTGRES_PASSWORD}) and injected at runtime from your platform's secret store, typically a Kubernetes Secret populated by an external secret operator or your vault. Where a build step truly needs a credential, such as a private npm registry during yarn install, use a BuildKit secret mount (RUN --mount=type=secret) so the value is available to that single command and is not written to a layer. Confirm your Docker and BuildKit versions support this in your build pipeline before relying on it.
Add a .dockerignore file before the first build. It should exclude .env*, *.pem, *.key, local app-config.local*.yaml files, .git, and any node_modules or dist output that should be rebuilt inside the image. Then check the result: build the image, run docker history --no-trunc on it, and search every layer and the exported filesystem for known token prefixes and high-entropy strings. Use a dedicated image scanner for secret detection in addition to vulnerability scanning, and repeat the check in CI for every build. Only push to a registry after the scan passes, because a secret pushed once remains in the registry's layer history even after you delete the tag.
Security and operational considerations for the image
A production image inherits every weakness in its base layers, its Node.js runtime and its Backstage packages, so the controls below work as a set. Each one shapes how quickly you can rebuild, what you can prove about a running image, and how much damage a compromised process can do.
Run as a non-root user
The Backstage backend should run under an unprivileged user with no need for root inside the container. Set a numeric UID in the Dockerfile with USER, make the application directories owned by that user only where writes are required, and keep the root filesystem read-only if your runtime allows it. Confirm that plugins you install do not expect to write to paths outside the designated data directory.
Choose a minimal base image
Pick a base image that matches the Node.js major version you are supporting and carries the fewest packages the runtime needs. Smaller images expose fewer packages to scanners and fewer binaries an attacker can use after a compromise. Confirm with your platform team which base images they already patch and sign, because an unmaintained base defeats every other control in this list.
Pin image digests
Reference the base image by its digest in the FROM line rather than by a mutable tag, so a rebuild reproduces the same layers. Record the digest that each release used. Tags such as a Node.js major tag move when the upstream image is republished, and a silent change to the base is hard to explain during an incident review.
Scan images in the pipeline
Run an image scanner in CI against every built image and fail the build on high and critical findings that have a fix available. Scan the Node.js runtime and the installed @backstage dependencies, not only the operating system packages. Decide who triages findings with no fix yet, and how long an exception may stand, before the first scan produces results.
Rebuild on a fixed patch cadence
The versioning policy says high and critical vulnerabilities are backported to releases from the last six months where feasible. Your rebuild process should track the Backstage releases in that window, follow the monthly main line and the weekly next line for planning, and rebuild when a backport lands. Name an owner for the rebuild and a target for applying it to production.
Record the version audit trail
Each image should carry labels or an SBOM that state the Backstage release, the Node.js version, the base image digest and the build commit. Store that record with the image digest so you can answer which running containers contain a vulnerable package. Ask your team how they will query this inventory during an incident, and test that query before you need it.
Questions teams ask before building their Backstage image
How much effort does a first production Backstage image take?
The effort depends mostly on how much the app differs from the default template. A first production image usually means setting a multi-stage Dockerfile, confirming the Node.js version, and deciding which app-config files ship and which values arrive at runtime. The main drivers are the number of plugins, whether you maintain custom plugins, whether packages come from a private registry, and how mature your CI pipeline is. Ask any partner to break its estimate into those drivers, and to state which parts are one-time setup and which recur with each Backstage upgrade. DeployOpen does not publish prices, so a figure follows from a written scope.
Can our in-house team build the Backstage image without a partner?
Yes, many platform teams do, especially if they already run Node.js builds and container pipelines. The work is manageable when the team knows its plugin list, has a way to install private packages during the build, and can keep secrets out of image layers. Teams usually struggle in three places: aligning the build image with the Node.js releases Backstage supports, deciding how configuration reaches the running container, and wiring the image into an upgrade routine. A partner is most useful when those decisions must be made quickly or documented for auditors. Your team should decide early which of these it will own.
What access does a partner need to build the image?
A partner needs read access to the Backstage repository and its package manifests, write access to a feature branch for the Dockerfile and CI job, and push access to the container registry for a test tag. It also needs a way to reference secrets without seeing their values, such as a secret store integration or CI secret variables, and a target Kubernetes or container platform to test the image against. Ask for least-privilege accounts that expire after the engagement, and for a list of who holds each credential. Confirm the exact access model with your security team before work starts.
How often must we rebuild the Backstage image?
Rebuild whenever you move to a new Backstage release, when a base image or Node.js line changes, and when a security fix requires a new build. Backstage ships a main release line monthly and a next release line weekly, and its versioning policy says high and critical vulnerabilities are backported to releases from the last six months where feasible. Choose one release line for production, pin the versions you deploy, and schedule rebuilds on a fixed cadence plus an out-of-band path for urgent fixes. The Backstage release and versioning policy is the right source to confirm the current cadence.
Which app-config files should go into the image?
Include only the defaults that are safe to publish, such as the base app-config.yaml with structure and non-sensitive settings. Keep environment-specific files and anything containing URLs tied to a single environment out of the image, and supply them at runtime through mounted files or environment variables. Never bake tokens, client secrets, database passwords or signing keys into any config file that goes into a layer. A useful check is to inspect the built image and confirm that no file or environment value contains a credential. Your security team should sign off on this list before the first production build.
What should we ask a partner for in a written scope?
Ask for a list of deliverables, including the Dockerfile, the CI job that builds and scans the image, a runbook for rebuilds, and the configuration contract that explains which values come from where. Ask how the scope handles custom plugins, private package access and Node.js version alignment, and what is excluded. Request acceptance criteria, such as a verified build from a clean checkout and a successful start against a test database, plus named responsibilities for handover and ongoing support. DeployOpen plans and implements work from a written scope agreed before work starts, so ask for the same clarity from any provider.
Does a multi-stage build matter for Backstage images?
Yes, because a multi-stage build keeps compilers, development dependencies and source-only files out of the final image. A common pattern is to install and build in an earlier stage, then copy only the runtime bundle and production dependencies into a slim final stage. This reduces the attack surface and makes it easier to see what the runtime needs. Compare that with a host build, where the artifacts are produced outside the container and copied in; that is faster to iterate but depends on the build machine matching the target runtime. Test both paths in your CI before choosing.
Limits to confirm in your environment
The Dockerfile is the easy part. Whether the image builds and runs in your organization depends on Node.js support, base image policy, private package access, native dependencies and secret delivery. Each of these is set by your environment, so confirm them before the first build.
Node.js versions for your release
Backstage supports two adjacent even-numbered Node.js releases at a time, and the set changes with the release line. As of Backstage 1.46.0 the supported versions were Node.js 22 and 24. Pick the Node.js version your target Backstage release supports, then pin the same major version in the build stage and the runtime stage so the image runs the code that was tested. Confirm the current pair against the versioning policy before you pin it.
Base image and registry policy
Your security team may require approved base images, a specific registry, image signing or scan results before anything is pushed. Check whether your chosen Node.js base image is on the approved list, who rebuilds it when the upstream image receives a patch, and whether your registry accepts the build's provenance metadata. Ask who owns the base image update cadence, since Backstage security fixes depend on rebuilding the image promptly.
Private npm package access during build
Many Backstage deployments include internal plugins or shared libraries published to a private npm registry. The build needs a token to fetch them, and that token must not persist in a layer. Confirm which registry hosts your packages, how your CI system injects the credential (for example, as a BuildKit secret mount), and whether your registry issues tokens scoped to read-only installs.
Native modules and build dependencies
Some plugins and their dependencies compile native code, such as modules that bind to system libraries. These need compilers, Python or headers in the build stage, and often a matching runtime library in the final image. Audit your plugin list with yarn install in a clean container, note any build failures, and confirm whether your base image permits the extra packages. Keep the runtime image free of compilers where you can.
Secret delivery at runtime
Secrets belong in your runtime platform, not in the image. Confirm how your secret store delivers values to the container, whether through Kubernetes Secrets, an external secrets operator or a vault agent, and how those values reach the environment variables that app-config references. Also confirm who can read the secret, how rotation changes the running pods, and whether your platform encrypts secrets at rest.
Config files included in the image
Decide which app-config files are baked into the image and which are mounted at deploy time. Environment-specific files, such as production URLs and resolver settings, usually belong outside the image so one artifact can run in every environment. Confirm with your platform team how configuration is mounted and whether your change process requires a rebuild for config changes.
How DeployOpen scopes and delivers the image work
DeployOpen plans, implements, hardens, documents and supports self-hosted open-source platforms inside the client's own infrastructure, including their cloud accounts, data centers and air-gapped networks. For a production Backstage image, the work is agreed as a written, fixed scope before any build starts. That scope names the deliverables: the Dockerfile and build pipeline, how the app-config.yaml layers are split between the image and the runtime environment, how secrets are injected without landing in image layers, how the image is scanned, and how it is rebuilt when the Backstage release line moves. Each item is written down so the client knows what they receive and what they must provide, such as the registry, the CI runner, and the list of plugins that need to be included.
Because Backstage ships a main release line monthly and a next release line weekly, the image is a moving target. DeployOpen can run the rebuild and upgrade work as an ongoing retainer rather than a one-off project, so that new base images, Node.js changes and security backports are applied on a schedule your team agrees. Confirm during scoping how often rebuilds should happen, who approves a promoted image, and where scan results are reviewed. DeployOpen does not publish a price list. A quote follows from the agreed scope, and the drivers of that quote are the number of custom plugins, the number of build targets and environments, the registry and CI setup, and whether the client wants ongoing retainer support after handover.

