Skip to content
← Containers · intermediate · 9 min · 04 / 06

Image Layers & Registries

How layer sharing works in practice, pushing and pulling efficiently, and running your own registry.

image layersregistryDocker HubGHCRself-hosted registryimage tagging

Real-World Analogy

A Git repository for your filesystem: each commit is a layer, you only transfer the diff when you push or pull, and multiple branches can share common history without duplicating it. Image registries work the same way — layers already on the server are skipped during push.

How Layer Sharing Works

When you push an image, Docker sends only layers that don’t already exist in the registry. When you pull, only missing layers are downloaded. This is why base images matter: if 100 services all use node:20-alpine, that layer is stored once and shared.

# Push an image — watch which layers are skipped
docker push myregistry.io/myapp:v1.2.0
# Pushing manifests for platform linux/amd64
# Layer sha256:abc... already exists   ← node:20-alpine layers
# Layer sha256:def... already exists   ← npm install layer (unchanged)
# Pushed sha256:xyz...                 ← only the changed app layer
# v1.2.0: digest: sha256:... size: 1234
# Inspect layers of an image
docker manifest inspect myapp:latest
# Shows each layer digest and size

# See which layers are shared between images
docker images --digests

Tagging Strategy

Tags are mutable pointers to image digests. A digest is immutable. Good tagging strategy gives you both:

# Semantic versioning + git SHA
docker build -t myapp:v2.1.3 -t myapp:v2.1 -t myapp:v2 -t myapp:latest .

# In CI: use git commit SHA for traceability
docker build \
  -t myregistry.io/myapp:${GIT_SHA} \
  -t myregistry.io/myapp:latest \
  .
docker push myregistry.io/myapp:${GIT_SHA}
docker push myregistry.io/myapp:latest

Reference images by digest in production — not tags:

# docker-compose.prod.yml
services:
  api:
    # Tag can be changed by anyone — digest is immutable
    image: myregistry.io/myapp@sha256:abc123def456...

Referencing by digest guarantees you’re running exactly what you tested, not whatever latest points to after the next push.

Public Registries

Docker Hub:

docker login
docker push username/myapp:v1.0.0

# Pull (public images don't need login)
docker pull username/myapp:v1.0.0

# Rate limits: 100 pulls/6hr (anonymous), 200/6hr (free account)
# Authenticated pulls from CI: use a service account token

GitHub Container Registry (GHCR):

# Authenticate with GitHub token
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin

docker push ghcr.io/username/myapp:v1.0.0

# In GitHub Actions — automatic authentication
- name: Login to GHCR
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

AWS ECR:

# Login (credentials from AWS CLI)
aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin \
    123456789.dkr.ecr.us-east-1.amazonaws.com

# Push
docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest

# ECR advantages: no pull limits, same-region pulls are free/fast,
# integrated with IAM for authentication

Running a Self-Hosted Registry

For air-gapped environments, caching, or cost control:

Docker Registry (official, minimal):

# docker-compose.yml for a private registry
services:
  registry:
    image: registry:2
    ports:
      - "5000:5000"
    environment:
      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data
    volumes:
      - ./registry-data:/data

  # Optional: web UI
  registry-ui:
    image: joxit/docker-registry-ui:latest
    ports:
      - "8080:80"
    environment:
      REGISTRY_TITLE: "My Registry"
      REGISTRY_URL: http://registry:5000
    depends_on:
      - registry
# Use it
docker push localhost:5000/myapp:v1.0.0
docker pull localhost:5000/myapp:v1.0.0

For production self-hosted: use Harbor or Gitea Container Registry — they add authentication, RBAC, vulnerability scanning, and a proper web UI.

Harbor (enterprise-grade):

# Install via Helm
helm repo add harbor https://helm.goharbor.io
helm install harbor harbor/harbor \
  --set expose.type=ingress \
  --set expose.ingress.hosts.core=registry.yourapp.com \
  --set externalURL=https://registry.yourapp.com \
  --set harborAdminPassword=secret

Image Scanning

Scan images for known vulnerabilities before deploying:

# Trivy (open source, fast)
trivy image myapp:latest
# 2024-01-15T10:00:00Z INFO Vulnerability scanning is enabled
# myapp:latest (alpine 3.19.0)
# Total: 3 (HIGH: 1, MEDIUM: 2)

# In CI: fail the build on HIGH+ vulnerabilities
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

# Grype (alternative)
grype myapp:latest

In GitHub Actions:

- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
    format: table
    exit-code: '1'
    severity: HIGH,CRITICAL

Multi-Platform Images

Build images that work on both x86_64 (AMD64) and ARM (Apple Silicon, Graviton):

# Enable buildx (multi-platform builder)
docker buildx create --name multiarch --use
docker buildx inspect --bootstrap

# Build and push for both platforms simultaneously
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag myregistry.io/myapp:v1.0.0 \
  --push \          # push directly (can't load multi-platform locally)
  .
# GitHub Actions: multi-platform build
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

Multi-platform images are stored as a manifest list — one tag points to multiple platform-specific digests. Docker automatically pulls the right one for the host architecture.

Layer Optimization for CI Speed

CI build time is mostly layer cache misses. Strategies:

Export and import the cache:

# GitHub Actions: cache Docker layers between runs
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build with cache
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: myapp:latest
    cache-from: type=gha          # read from GitHub Actions cache
    cache-to: type=gha,mode=max   # write back (max = all layers, not just final)

Use a registry cache:

# Use the registry itself as a cache store
docker buildx build \
  --cache-from type=registry,ref=myregistry.io/myapp:cache \
  --cache-to type=registry,ref=myregistry.io/myapp:cache,mode=max \
  --push \
  --tag myregistry.io/myapp:latest \
  .

This pulls the previous build’s layers from the registry and uses them as cache for the current build — even on a fresh CI runner with no local cache.