Container Images in GCP Explained: Docker, Artifact Registry, and Best Practices
Every Cloud Run service and GKE pod runs a container image. Understanding what images are, how to build them well, and where to store them in GCP is one of the most practical skills you can develop early on. This page covers the whole picture: from the concept itself through to Dockerfiles, layer caching, Artifact Registry, and the most common mistakes beginners make.
Simple explanation
A container image is a self-contained, portable package of your application. It bundles your code, the runtime it needs (Python, Node, Go, or whatever you use), all dependencies, and the instructions to start the app into a single unit you can build once and run anywhere.
Think of a container image like a shipping container. Before shipping containers existed, cargo had to be repacked every time it changed ships or trucks. After they existed, you load the container once at the source and it moves between ship, rail, and truck without being repacked. A container image works the same way: build it once on your laptop, and it runs identically in staging, in CI, and in production on GCP.
The key distinction beginners often miss: an image is static. It does not run. A container is a running instance of an image, the same way a class is a blueprint and an object is an instance of it. Cloud Run and GKE pull images from a registry and create containers from them.
What is a container image in GCP?
In GCP, a container image is the deployable artefact for most container-based services. It contains:
- your application code
- the runtime (e.g. Python 3.12, Node 20)
- all installed dependencies
- environment defaults and startup commands
- the filesystem layout the app expects
When you deploy a Cloud Run service, GCP pulls the image you specify from Artifact Registry and starts a container from it. When you deploy a workload to GKE, each pod starts a container from an image stored in the same place. The image format is standard: it follows the OCI (Open Container Initiative) specification, which means images built locally with Docker run on GCP without modification.
Artifact Registry is GCP’s standard registry for storing container images. The older Container Registry (gcr.io) is being discontinued. All new projects should use Artifact Registry.
How container images fit into a typical GCP workflow
Here is the end-to-end flow from code to running service. Each step is covered in more detail further down this page.
- Write application code. A Python Flask API, a Node.js server, a Go binary. Any language works.
- Create a Dockerfile. The Dockerfile is the recipe: it tells Docker how to build the image from a base image, install dependencies, copy your code, and set the start command.
- Build the image. Run
docker buildlocally or use Cloud Build to build without local Docker dependencies. - Push the image to Artifact Registry. The built image gets a name like
us-central1-docker.pkg.dev/PROJECT_ID/my-repo/my-app:v1and is uploaded to GCP. - Deploy to Cloud Run or GKE. You reference that image name when deploying. GCP pulls the image and starts containers from it.
- Roll forward or roll back. New versions get new tags or digests. Rolling back means pointing your service at an earlier image. The old image stays in Artifact Registry.
This workflow is the same whether you are deploying once manually or automating it with a CI/CD pipeline.
Dockerfile basics
A Dockerfile is a text file containing ordered instructions that tell Docker how to build your image. Each instruction creates a layer. Understanding layers is the key to fast, efficient builds.
# Python 3.12 slim base image
FROM python:3.12-slim
# Set the working directory inside the container
WORKDIR /app
# Copy only the dependency file first
COPY requirements.txt .
# Install dependencies (cached unless requirements.txt changes)
RUN pip install --no-cache-dir -r requirements.txt
# Now copy the rest of the source code
COPY . .
# Tell Docker how to start the app
CMD ["python", "app.py"]The COPY requirements.txt step comes before COPY . .
deliberately. If you copy all your source code first, every code change
invalidates the cache and Docker reinstalls all dependencies from scratch.
Splitting the copy lets Docker reuse the dependency layer unless
requirements.txt actually changed.
Layer caching and instruction order
Every Dockerfile instruction that modifies the filesystem creates a layer. Docker stores each layer separately and reuses cached layers on subsequent builds, as long as nothing above that layer changed.
Cache invalidation works top-to-bottom. As soon as one layer changes, every layer below it is rebuilt from scratch. This means instruction order is not just style. It directly affects how long your builds take.
# Bad: source code copied before dependencies are installed
FROM python:3.12-slim
WORKDIR /app
COPY . . # Invalidated on every code change
RUN pip install -r requirements.txt # Reinstalls everything every build
CMD ["python", "app.py"]# Good: dependencies installed before source code is copied
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . # Only invalidated if requirements.txt changes
RUN pip install -r requirements.txt # Cached unless dependencies actually change
COPY . . # Rebuilt on code changes, but this is fast
CMD ["python", "app.py"]Think of layers like a stack of sticky notes. Each note records a change. When you edit the third note, you have to rewrite every note below it because the stack no longer makes sense without them. Put your slowest, least-changing work near the bottom of the stack and your fastest, most-changing work near the top.
Multi-stage builds
A multi-stage build uses two or more FROM instructions in a
single Dockerfile. The first stage (the builder) has all the tools needed
to compile or bundle the app. The second stage (the runtime) contains only
what is needed to run it.
# Stage 1: builder stage with full Go toolchain
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .
# Stage 2: runtime stage with tiny distroless base
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]The Go compiler, test dependencies, and source code never appear in the final image. Only the compiled binary and the minimal distroless base are included. The result is an image under 10MB instead of 800MB+.
Multi-stage builds are especially valuable for compiled languages like Go,
Rust, and Java, but they also help with JavaScript apps where the build step
produces a dist folder. The builder stage runs
npm run build; the runtime stage copies only the built output
into a slim Node or Nginx base.
Choosing a base image
The base image you choose has a significant impact on image size, startup time, and security posture. Here is a comparison of the four main types:
| Base image type | Example | Typical size | Best use case | Main trade-off |
|---|---|---|---|---|
| Full | python:3.12 | 900MB+ | Local development; debugging | Far too large for production; slow pulls |
| Slim | python:3.12-slim | 130MB | Most production workloads | Some build tools not present; easy to add |
| Alpine | python:3.12-alpine | 50MB | Minimal images; Go, static binaries | musl libc can break packages compiled for glibc |
| Distroless | gcr.io/distroless/python3 | 30–50MB | Production; tightest security posture | No shell, no package manager; harder to debug |
Start with slim. It is a fraction of the size of the full image, compatible with virtually all packages, and easy to work with. When you are ready to tighten your production setup, move to distroless for compiled languages or services where you do not need an interactive shell. Avoid Alpine unless you know your dependencies are musl-compatible. The compatibility issues catch many beginners off guard.
The .dockerignore file
When you run docker build, Docker sends the entire contents of
the current directory to the build daemon as the build context. This
happens before any instructions run. Without a .dockerignore file,
the context includes node_modules, .git, test
fixtures, and local .env files.
If your Dockerfile contains COPY . . and you have no
.dockerignore, local .env files and credentials
can be baked into the image. Once pushed to a registry, those secrets are
exposed to anyone who can pull the image. Add a .dockerignore
before writing your first COPY . . instruction.
# .dockerignore
.git
.gitignore
node_modules
__pycache__
*.pyc
.env
.env.*
.env.local
tests/
docs/
README.md
*.log
dist/
.DS_StoreA .dockerignore file belongs in every project that uses Docker.
Create it at the same time as the Dockerfile. Storing secrets in
Secret Manager and
loading them at runtime is safer than any exclusion list.
Artifact Registry overview
Artifact Registry is the managed service in GCP for storing container images and other build artefacts. It replaced the older Container Registry (gcr.io) and adds support for npm, Maven, PyPI, Helm charts, and other formats alongside Docker.
Images in Artifact Registry are organised into repositories. A repository belongs to a specific region and holds images with a consistent naming format:
REGION-docker.pkg.dev/PROJECT_ID/REPO_NAME/IMAGE_NAME:TAG
For example: us-central1-docker.pkg.dev/my-project/backend/api:v2.1
Teams use Artifact Registry for four main reasons:
- Storage. Images are stored close to where they run, reducing pull latency.
- Access control. IAM roles control who can push and pull from each repository.
- Cleanup. Retention policies automatically delete old or untagged images.
- Vulnerability scanning. Artifact Analysis scans images for known CVEs automatically after push.
# Create a Docker repository in Artifact Registry
gcloud artifacts repositories create my-repo \
--repository-format=docker \
--location=us-central1
# Configure Docker to authenticate with Artifact Registry
gcloud auth configure-docker us-central1-docker.pkg.dev
# Build, tag, and push an image
docker build -t us-central1-docker.pkg.dev/PROJECT_ID/my-repo/my-app:v1 .
docker push us-central1-docker.pkg.dev/PROJECT_ID/my-repo/my-app:v1
# List images in a repository
gcloud artifacts docker images list \
us-central1-docker.pkg.dev/PROJECT_ID/my-repoFor a deeper walkthrough of repository setup, access control, and cleanup policies, see the Artifact Registry Overview and Artifact Registry Best Practices.
Container Registry vs Artifact Registry
If you see documentation or tutorials referencing gcr.io, they
are using the old Container Registry. Here is the key difference:
Container Registry (gcr.io) is being discontinued by Google. Do not use it for new projects. If you have existing images at gcr.io, plan a migration to Artifact Registry when you get the chance.
| Feature | Container Registry | Artifact Registry |
|---|---|---|
| Status | Being discontinued | Actively maintained |
| Domain format | gcr.io/PROJECT_ID/image | REGION-docker.pkg.dev/PROJECT_ID/REPO/image |
| Package types supported | Docker only | Docker, npm, Maven, PyPI, Helm, and more |
| Repository organisation | Project-level only | Named repositories per project and region |
| Cleanup policies | Manual | Automated retention policies |
| Recommendation for new projects | Do not use | Use this |
Tags vs digests
Every image in a registry can be referenced in two ways.
Tags are human-readable labels like :v1.2,
:latest, or :production. They are mutable: pushing
a new image with the same tag silently moves the pointer to the new image.
Tags are useful during development and for communicating intent, but they are
not reliable identifiers for production deployments.
Digests are content-addressed identifiers like
sha256:a8f3c2d…. They are computed from the image content
and are immutable: a digest will always refer to exactly the image that
produced it. If you pin a deployment to a digest, it will run the same
image every time, regardless of what happens to the tag.
Using only :latest in production means your rollback strategy
does not work. If you push a broken image and it overwrites :latest,
there is no previous version to roll back to by name. Always tag with a
version number or commit SHA in addition to :latest.
The practical advice: use tags in your CI/CD workflow for readability (tag with the git commit SHA, for example). When deploying to production or writing rollback procedures, resolve the tag to a digest and use the digest reference. This is what Binary Authorization enforces by default: it requires a digest-based image reference to verify attestations.
Building with Cloud Build
Cloud Build is GCP’s managed build service. It builds container images in the cloud without requiring Docker installed on your machine. Builds run in a consistent, isolated environment and push directly to Artifact Registry, which makes it the standard approach in CI/CD pipelines.
# Build and push in one command, no local Docker needed
gcloud builds submit \
--tag=us-central1-docker.pkg.dev/PROJECT_ID/my-repo/my-app:v1 \
.# cloudbuild.yaml: tag with the git commit SHA for traceability
steps:
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'
- '.'
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'Using $SHORT_SHA tags each image with the git commit hash. This
makes images traceable back to the exact commit that produced them, which is
essential for debugging and rollbacks. For a detailed walkthrough of CI/CD
with Cloud Build, see
Building Docker Images with Cloud Build.
Security and best practices
Never use ENV MY_API_KEY=… or COPY .env . in a
Dockerfile. Credentials written into an image are visible to anyone who can
pull it, even if the layer was later overwritten. Use
Secret Manager
and inject secrets at runtime.
- Use small base images. Fewer installed packages means fewer potential CVEs. Slim and distroless bases have smaller attack surfaces than full images.
- Scan images after push. Enable Artifact Analysis in Artifact Registry. It automatically scans pushed images for known CVEs and surfaces the results in the GCP console.
- Pin base image versions. Using
FROM python:3.12-sliminstead ofFROM python:slimprevents surprise upgrades when base images change. - Rebuild images regularly. Even if your app code has not changed, the base image may have had security patches applied. Rebuild to pick them up.
- Use non-root users where possible. Add a
USERinstruction to run the app as a non-root user inside the container. - Consider Binary Authorization for production. Binary Authorization enforces that only attested, approved images are deployed to Cloud Run or GKE.
Cloud Run and GKE: two ways to run the same image
Container images are the common currency between Cloud Run and GKE. You build and push the image once; the platform you choose determines how it runs.
Cloud Run is serverless. You deploy a service, specify the image, and Cloud Run handles scaling, load balancing, and infrastructure. Each new deployment creates a new revision that references a specific image. Traffic can be split between revisions, making canary rollouts and rollbacks straightforward. See the Cloud Run Overview for more, and Cloud Run Security for securing the service account and invocation.
GKE is managed Kubernetes. Your pods reference a container image in their spec, and Kubernetes pulls and runs it across nodes in the cluster. You have more control over networking, scheduling, and persistent workloads, though with more configuration to manage. See the GKE Overview for when this trade-off makes sense.
If you are not sure which to use, the Cloud Run vs GKE vs VMs comparison works through the decision.
Container images vs VM images
GCP has two different types of images that beginners sometimes confuse:
| Container image | VM image (machine image) | |
|---|---|---|
| What it contains | App + runtime + dependencies | Full OS + app + config + disk state |
| Used by | Cloud Run, GKE, Cloud Build | Compute Engine, instance templates |
| Size | Megabytes (often 10–200MB) | Gigabytes (typically 10–50GB) |
| Start time | Seconds | Minutes |
| Stored in | Artifact Registry | Cloud Storage / Compute Engine image storage |
Container images share the host OS kernel and include only the application layer above it. VM images include a full operating system. This makes container images much smaller and faster to start, but they depend on the host kernel. If you need a specific OS kernel version or kernel modules, a VM is the right tool. See the VM Images guide for how Compute Engine images work.
When to use container images
Container images are the right packaging format when:
- you are deploying a custom application to Cloud Run or GKE
- you want consistency between local development, staging, and production
- you are building a CI/CD pipeline that needs reproducible, versioned builds
- you need to run multiple services with different runtimes on the same infrastructure
- you want fast rollbacks by pointing the service at an older image
You do not need to think deeply about image internals yet if you are just getting started with Cloud Run and your app is small. A basic Dockerfile with a slim base image and correct layer ordering is enough. The optimisations on this page become meaningful when build times or image sizes start to slow you down.
Common beginner mistakes
No .dockerignore file. Without it,
node_modules,.git, and local.envfiles end up in the build context. At best this slows builds. At worst, credentials get baked into the image and pushed to Artifact Registry.Copying source code before installing dependencies. This is the most common Dockerfile mistake. Any code change invalidates the dependency cache and triggers a full reinstall. Copy your dependency file first, install, then copy the rest of your code.
Cleaning package caches in a separate RUN instruction. Each
RUNis a separate layer. Runningrm -rf /var/lib/apt/lists/*in a new instruction does not shrink the previous layer — the cache is still there. Chain cleanup in the sameRUN:apt-get install -y pkg && rm -rf /var/lib/apt/lists/*.Using
:latestonly in production.:latestis a mutable tag. It points to a different image every time you push. If you need to roll back, there is no stable reference to the previous version. Always tag with a version number or commit SHA alongside:latest.Baking secrets into images. Using
ENV MY_API_KEY=…orCOPY .env .in a Dockerfile puts credentials into the image permanently. Use Secret Manager and inject secrets at runtime.Choosing Alpine without checking compatibility. Alpine uses musl libc instead of glibc. Many Python packages with C extensions fail to install on Alpine without extra work. Start with slim unless you have a specific reason for Alpine.
Confusing image tags with digests. Tags are mutable labels; digests are immutable identifiers. For deterministic production deployments and rollback procedures, use digests.
Summary
- A container image is a packaged application with its runtime and dependencies: a static, portable artefact that Cloud Run and GKE run containers from
- Layer caching is fastest when slow, rarely-changed steps (dependency install) come before fast, frequently-changed steps (source copy)
- Multi-stage builds keep build tools out of the final image, cutting size by 90% or more in compiled language projects
- Use slim base images for most production workloads; consider distroless for tighter security posture; avoid Alpine unless your dependencies are musl-compatible
- Use Artifact Registry (pkg.dev) for all new projects; Container Registry (gcr.io) is being discontinued
- Tags are mutable; digests are immutable: use digests for pinned production deployments and rollback references
Frequently asked questions
What is a container image in GCP?
A container image is a packaged, portable snapshot of an application and everything it needs to run: the runtime, dependencies, config, and code. In GCP, services like Cloud Run and GKE pull an image from a registry (usually Artifact Registry) and run containers from it. The image itself never changes; you build a new image for each new version of your app.
What is the difference between a container image and a container?
An image is a static, read-only package. A container is a running instance of that image. The analogy is a class versus an object in code, or a recipe versus a meal. One image can be used to start many containers at the same time. Cloud Run and GKE both pull the same image and create separate container instances from it.
What is the difference between Container Registry and Artifact Registry?
Container Registry (gcr.io) is the older GCP service for Docker images. Artifact Registry (pkg.dev) is its replacement, supporting Docker images plus npm, Maven, PyPI, and other package types. Google has announced Container Registry will be discontinued, so new projects must use Artifact Registry. Image naming changes from gcr.io/PROJECT_ID/image to REGION-docker.pkg.dev/PROJECT_ID/REPO/image.
How do I make my container image smaller?
Three steps work well together: use a slim or distroless base image (python:3.12-slim instead of python:3.12); use multi-stage builds so build tools stay in the builder stage and only the compiled output lands in the final image; clean up package manager caches in the same RUN instruction that installs packages (apt-get install && rm -rf /var/lib/apt/lists/*). Smaller images start faster on Cloud Run, cost less to store, and have fewer packages with potential CVEs.
Should I deploy to Cloud Run using a tag or a digest?
For production deployments, use a digest. A tag like :v1.2 is a mutable pointer: someone can push a new image with the same tag and your next deployment will run different code. A digest (sha256:abc123...) is a permanent, content-addressed identifier that will always refer to the exact image that was built. Use tags in development workflows for readability, and resolve to digests when pinning production deployments or writing rollback procedures.