Building rebaseable container images with Docker from scratch

When you enable automatic base image updates (or runtime rebasing) on Cloud Run, the platform patches operating system packages and language runtimes (such as Node.js or Python) without rebuilding your container image. The recommended way to use base image updates is to omit the Dockerfile from your project directory and use a source-based deployment. Still, I wanted to build a rebaseable container image myself with Docker to see how these images are structured and what Cloud Run actually expects.

A regular container image bundles both the base image (the OS packages and language runtime) and your application layers. For runtime rebasing to work, Cloud Run needs an image that contains only the application layers and no base image, so the platform can rebase those layers onto a Google-managed base image when starting a container instance:

A comparison between a regular container image and a rebaseable container image. A regular image includes both application layers and a base image. A rebaseable image contains only the application layers, allowing Cloud Run to rebase them onto a base image at runtime.

If you feel strongly that a container image needs to be an immutable, self-contained archive that carries everything it needs to run, structuring container images this way might make you feel a bit anxious. That's ok. I hear you. Put that feeling aside for a bit to see how the pieces fit together. (If you want the full background on the feature first, read Automating OS patches on Cloud Run and How runtime rebasing works on Cloud Run.)

Stripping base images with multi-stage Dockerfiles

In Docker, a multi-stage Dockerfile lets you use multiple FROM instructions in a single build. You can use an image with your language's build tools and package manager (such as node:24-slim) in the first stage to install dependencies and build or compile your application, and then copy only the finished application artifacts into a fresh final stage. To create a final image without a base image, start that final stage with FROM scratch. In Docker, scratch is a reserved, completely empty image: it has no operating system, no system libraries, no shell, and no language runtime.

To try this out, create a new directory for the project and enter it:

mkdir docker-example-app && cd docker-example-app

Initialize a Node.js project and install Express:

npm init -y && npm install express
Wrote to /path/to/docker-example-app/package.json:
...
added 65 packages, and audited 66 packages in 944ms

Next, create an index.js that responds to HTTP requests on port 8080:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello, automatic updates!');
});

const port = process.env.PORT || 8080;
app.listen(port, () => console.log(`Listening on port ${port}`));

Now create a multi-stage Dockerfile that installs production dependencies from package-lock.json (npm ci --omit=dev) in the first stage and copies the result onto scratch in the final stage:

# Stage 1: Install dependencies using a standard Node.js image
FROM node:24-slim AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . ./

# Stage 2: Copy only the app artifacts onto an empty image
FROM scratch
WORKDIR /workspace
# Match the unprivileged www-data user (UID 33) in the base image
COPY --from=builder --chown=33:33 /usr/src/app/ ./
USER 33:33
CMD ["node", "index.js"]

Even though scratch has no files of its own, the second stage configures the image metadata and filesystem paths to match the base image that Cloud Run rebases your image onto.

Cloud Run provides a set of Google-managed, Ubuntu-based base images, covering languages such as Node.js, Python, Go, Java, Ruby, PHP, and .NET (for example, nodejs24), plus an OS-only image (osonly24) for compiled binaries.

In Stage 2 of the Dockerfile, WORKDIR /workspace puts your application files in the default working directory that these base images expect. Next, --chown=33:33 and USER 33:33 set file ownership and the runtime user to numeric ID 33 (because scratch has no /etc/passwd file during the build, you can't reference a username by name). Once Cloud Run rebases your image onto the Ubuntu-based base image, UID 33 maps to the unprivileged www-data user, so your container doesn't run as root.

Testing scratch images locally with Docker

Before pushing anything to Google Cloud, build the image locally with a short tag (docker-example-app) and try running it on your machine. Because the final image has no base image or Node.js runtime, docker build succeeds, while docker run is expected to fail:

docker build -t docker-example-app .

Docker executes the node:24-slim builder stage and copies the /usr/src/app/ directory into the empty stage-1 (FROM scratch) image:

[+] Building 4.6s (12/12) FINISHED                         docker:desktop-linux
 => [internal] load build definition from Dockerfile                       0.0s
 => [internal] load metadata for docker.io/library/node:24-slim            1.4s
 => [builder 1/5] FROM docker.io/library/node:24-slim@sha256:a5f4366e8346  0.0s
 => [stage-1 1/2] WORKDIR /workspace                                       0.0s
 => [builder 2/5] WORKDIR /usr/src/app                                     0.0s
 => [builder 3/5] COPY package*.json ./                                    0.0s
 => [builder 4/5] RUN npm ci --omit=dev                                    2.6s
 => [builder 5/5] COPY . ./                                                0.1s
 => [stage-1 2/2] COPY --from=builder --chown=33:33 /usr/src/app/ ./       0.1s
 => exporting to image                                                     0.1s
 => => naming to docker.io/library/docker-example-app:latest               0.0s

Now try running that image locally to see it fail as expected:

docker run --rm docker-example-app

Docker fails right away with an OCI runtime error:

docker: Error response from daemon: failed to create task for container:
failed to create shim task: OCI runtime create failed: runc create failed:
unable to start container process: error during container init:
exec: "node": executable file not found in $PATH

Your image sets node index.js as its startup command (CMD) in the OCI metadata and includes /workspace in its filesystem layer, and nothing else. Because Stage 2 started FROM scratch, there's no node binary in $PATH and none of the shared libraries it needs to run. On its own, this image is only the top half of a container image.

Deploying scratch images to Cloud Run

Now give that image to Cloud Run and ask the platform to supply the missing base image. Because Cloud Run pulls container images from Artifact Registry, the next few steps create a Docker repository, push your local scratch image to it, and deploy that image to a new Cloud Run service with --base-image nodejs24. By the end, the exact image that failed to start on your machine runs on a public Cloud Run URL, rebased onto the nodejs24 base image.

Set your Google Cloud project ID in your shell:

export PROJECT_ID="YOUR_PROJECT_ID"

Create an Artifact Registry repository in europe-west1 to store the image:

gcloud artifacts repositories create cloud-run-images \
  --repository-format=docker \
  --location=europe-west1 \
  --description="Docker repository for runtime rebasing example" \
  --project $PROJECT_ID

Configure Docker to authenticate with your regional Artifact Registry endpoint:

gcloud auth configure-docker europe-west1-docker.pkg.dev

Tag your local docker-example-app image with the Artifact Registry repository path:

docker tag docker-example-app \
  europe-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-images/docker-example-app

Push the tagged image to your repository:

docker push europe-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-images/docker-example-app

Because the image only contains your index.js and node_modules directory, the entire upload is under a megabyte:

The push refers to repository [europe-west1-docker.pkg.dev/YOUR_PROJECT_ID/cloud-run-images/docker-example-app]
e3f3b65b3ddb: Pushing  664.1kB/664.1kB
96178ea028a7: Pushed
ed1ae0892f5c: Pushed
e3f3b65b3ddb: Pushed
latest: digest: sha256:73ea8695b6c2840a0da2596b48fa006204c2c0d483fc3efa404ed2fde08946c3 size: 855

Deploy the container image to Cloud Run:

gcloud run deploy docker-example-app \
  --image europe-west1-docker.pkg.dev/$PROJECT_ID/cloud-run-images/docker-example-app \
  --base-image nodejs24 \
  --region europe-west1 \
  --no-invoker-iam-check \
  --project $PROJECT_ID
  • --image points Cloud Run to the scratch image you pushed to Artifact Registry.
  • --base-image nodejs24 tells Cloud Run to rebase that image onto the nodejs24 base image.
  • --no-invoker-iam-check disables the Cloud Run Invoker IAM check so you can test the public endpoint directly with curl (I don't like that flag name, and I'm thankful Google is shipping --public as an alias, now in gcloud alpha).
Deploying container to Cloud Run service [docker-example-app] in project [YOUR_PROJECT_ID] region [europe-west1]
✓ Deploying new service... Done.
  ✓ Creating Revision...
  ✓ Routing traffic...
Done.
Service [docker-example-app] revision [docker-example-app-00001-4jv] has been deployed and is serving 100 percent of traffic.
Service URL: https://docker-example-app-1234567890.europe-west1.run.app

Verifying rebased images at runtime

Once the deployment finishes, fetch the service URL and send a request:

SERVICE_URL=$(gcloud run services describe docker-example-app \
  --region europe-west1 \
  --project $PROJECT_ID \
  --format='value(status.url)')

curl $SERVICE_URL

The service responds:

Hello, automatic updates!

The exact image that failed locally with executable file not found in $PATH now runs on Cloud Run. Before starting the container instance, Cloud Run rebased your /workspace application layer onto the nodejs24 base image (containing Ubuntu system packages and the node binary). Whenever Google releases a security patch for Ubuntu or Node.js 24, Cloud Run replaces that base image with a newer version without touching your application layer.

Cloud Run attaches the exact version of the base image to every stdout log entry under the run.googleapis.com/base_image_versions label. You can inspect it by querying Cloud Logging with a --format expression that extracts that label:

gcloud logging read 'resource.type="cloud_run_revision" '\
'AND resource.labels.service_name="docker-example-app" '\
'AND logName="projects/'$PROJECT_ID'/logs/run.googleapis.com%2Fstdout"' \
  --project $PROJECT_ID \
  --limit 1 \
  --format="value(labels.['run.googleapis.com/base_image_versions'])"
us-docker.pkg.dev/serverless-runtimes/google-24/runtimes/nodejs24:nodejs24_20260926_24_19_0_RC00

Breaking rebaseable seams in Dockerfiles

For runtime rebasing to work, there needs to be a clean separation (a rebaseable seam) between the base image and your application layers. Concretely, this means that your application layers can't contain files that overwrite files in the base image.

In a typical single-stage Dockerfile, it's common to install extra system packages with apt-get:

FROM node:20
# This command breaks the rebaseable seam!
RUN apt-get update && apt-get install -y imagemagick
COPY . .

When Docker runs apt-get install, the resulting layer adds the imagemagick binaries to /usr/bin/, writes shared libraries into /usr/lib/, updates the linker cache in /etc/ld.so.cache, and rewrites the package database in /var/lib/dpkg/status. If a platform replaces the node:20 base image underneath that layer with a newer version, your apt-get layer sits on top and overwrites the new base image's package state and shared libraries with the older files from when you ran docker build.

Starting the final stage FROM scratch and only copying your app into /workspace keeps that seam clean. Because scratch has no shell and no package manager, you can't run apt-get install in the final stage. If you run apt-get install -y imagemagick in the builder stage instead, the binaries in /usr/bin/ and libraries in /usr/lib/ stay behind in Stage 1 when Stage 2 copies /usr/src/app/ to /workspace. And if you try to work around that by copying /usr/bin/ or /usr/lib/ from the builder into scratch, those copied files sit in the upper layer and overwrite the files in the base image, breaking the seam all over again.

Now that you've seen how the pieces fit together, you can go back to using a source-based deployment without a Dockerfile (gcloud run deploy --source . --base-image nodejs24). I hope this helped clarify how runtime rebasing works!

Signature