Docker in Practice #5 Pushing to Registries and Tag Strategy — The :latest Trap

9 min read

If #4 was about the build, this one is about what happens after the push. Where to push, what tags to apply, how to clean up old images.

This post in the Docker in Practice track:

A single character in a tag can shake production. It looks trivial on the surface, but sorting it out once before going live makes life much easier later.

Registry — where do you put it? #

Five main options.

RegistryWhere it fitsFree tierAuth
Docker HubThe oldest, the public standard1 private, pull rate limitsAccount + PAT
GHCR (GitHub Container Registry)Naturally tied to your GitHub repoEffectively unlimited (public/private)GITHUB_TOKEN
AWS ECRWhen you want it inside AWS infra500MB free / GB-priced afterIAM
Google Artifact RegistryThe GCP-side standard0.5GB free / priced aftergcloud / WIF
Private registryRegulated environmentsSelf-managed

A one-line decision rule:

  • If you’re hosting code on GitHub, use GHCR. No separate auth setup — GITHUB_TOKEN just works. The free tier is so generous you don’t have to think about it.
  • If you’re deploying on AWS (ECS/EKS), use ECR. Same-region pulls are fast, and IAM gives clean permission management.
  • If it’s public OSS, use Docker Hub. Search and discoverability are unmatched. But the anonymous pull limit (100/hour/IP) regularly catches CI jobs pulling base images.

This post uses GHCR as the baseline. ECR was covered in detail in AWS track #2 ECR.

Why tags are hard #

A Docker “tag” is really just a label attached to an image — there’s no enforcement. You can push myapp:1.2.3 today and overwrite the same tag with a different image tomorrow. That’s where every trap starts.

Tag overwrite — nothing stops you
docker push ghcr.io/me/app:1.2.3   # first push
# ... code changes ...
docker push ghcr.io/me/app:1.2.3   # overwrites with a different image

What this means in production is what the next sections are about.

Why :latest is dangerous #

:latest has no special meaning that Docker assigns. It’s just the default tag attached when you omit one. But people unconsciously read it as “the newest version.”

Omit a tag → :latest
docker pull nginx          # actually docker pull nginx:latest
docker run myapp           # actually docker run myapp:latest

Using :latest in production causes problems like:

1. You can’t tell which image is running. “Which code is the production web container running?” → “myapp:latest.” That’s not an answer. :latest keeps pointing at different things over time.

2. You can’t roll back. When a new deploy breaks, “go back to the previous :latest” is meaningless. The previous :latest is a label that no longer exists.

3. Debugging breaks. Different nodes can have different images cached under the same tag. You hit cases where node A’s :latest and node B’s :latest behave differently.

4. Cache invalidation doesn’t work. Kubernetes’ imagePullPolicy: IfNotPresent won’t re-pull when the tag is the same. Even if you push over :latest, nodes that already have it cached keep using the old image. The assumption that all nodes run the same image breaks.

5. Build and deploy aren’t separated. It’s hard to guarantee “the thing I built is exactly what got deployed.” Especially if you don’t push an immutable tag like :sha-abc1234 alongside :latest, the link between a build and an image breaks down over time.

So when do you use :latest? Local dev/experiments. Bases for CI tools (e.g., actions/cache). And README “one-liner to get started” examples. Never use it for production deployments.

Tag strategy — multiple tags on one image #

The standard play is attaching multiple tags to a single image at the same time. The same image visible under several labels.

Tags on a single image
ghcr.io/me/app:sha-a1b2c3d         ← immutable, always this commit
ghcr.io/me/app:1.4.2               ← semver, exact version
ghcr.io/me/app:1.4                 ← semver minor (updates often)
ghcr.io/me/app:1                   ← semver major
ghcr.io/me/app:main                ← branch (updates often)
ghcr.io/me/app:latest              ← (internal / tutorial)

What each tag is for:

  • sha-...immutable. Once pushed, never changes. Recommended for the image: field in production deploys. You can trace exactly which code is running.
  • 1.4.2 — semver patch. Created when you push a release tag. Human-readable.
  • 1.4, 1 — semver minor/major. Auto-updating tags. Use these when the intent is “give me the latest patch on 1.4.” Don’t pin them in production — use the exact version like 1.4.2.
  • main — branch. Useful for auto-deploying to dev/staging.
  • latest — see above.

The metadata-action from #4 builds all of these in one shot.

metadata-action revisited — applying the tag strategy
- id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=sha,prefix=sha-,format=short        # always
      type=ref,event=branch                     # main, dev
      type=ref,event=pr                         # pr-123
      type=semver,pattern={{version}}           # on v1.2.3 push: 1.2.3
      type=semver,pattern={{major}}.{{minor}}   # 1.2
      type=semver,pattern={{major}}             # 1
      type=raw,value=latest,enable={{is_default_branch}}

Immutable tags — enforcing them via policy #

GHCR allows pushing over the same tag. You can’t block it via policy. So you define immutable tags by convention and use only those for production deploys.

  • Our agreement: sha-* and ^v?\d+\.\d+\.\d+$ are immutable. Once pushed, never overwritten.
  • Auto-updating tags (main, latest, 1.4) are intentionally mutable.

Some other registries can enforce this via policy.

  • AWS ECR has tagMutability: IMMUTABLE, which actually rejects re-pushes of the same tag. Recommended for production registries.
  • GCP Artifact Registry supports something similar.
  • GHCR / Docker Hub has no policy enforcement. You need convention plus a CI check.

The CI check is simple.

Protecting immutable tags
- name: Check tag uniqueness
  if: startsWith(github.ref, 'refs/tags/v')
  run: |
    TAG="${GITHUB_REF#refs/tags/}"
    if docker manifest inspect ghcr.io/${{ github.repository }}:${TAG#v} 2>/dev/null; then
      echo "Tag ${TAG} already exists — refusing to overwrite" >&2
      exit 1
    fi

Which tag belongs in production — the conclusion #

In production deploy manifests (e.g., ECS task definitions, Kubernetes Deployments, compose) the image: field should be:

Recommended — SHA tag
image: ghcr.io/me/app:sha-a1b2c3d

Pinning a SHA tag is the standard. Why:

  • It’s clear exactly which code is running.
  • Safe even if other tags pointing at the same image get overwritten.
  • Rollback is trivial — change one line in the manifest to the previous SHA.
  • Consistency across nodes is guaranteed.

Semver tags are for humans reading the README/changelog and for external users (library images). Not what you pin inside production manifests.

Image size and retention #

Along with the tag strategy, how long to keep images is worth deciding before going live. If CI builds an image on every PR/push, you’ll accumulate hundreds in a month. At 100MB each, that’s 30GB.

Cleanup approaches per registry:

GHCR — set a retention policy in the repo settings → Packages. Or wire actions/delete-package-versions into a workflow.

GHCR auto-cleanup
name: Clean up old images

on:
  schedule:
    - cron: '0 3 * * 0'  # every Sunday at 03:00 UTC

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/delete-package-versions@v5
        with:
          package-name: 'app'
          package-type: 'container'
          min-versions-to-keep: 20      # keep at least 20
          delete-only-untagged-versions: false
          ignore-versions: '^(latest|main|v\d+\.\d+\.\d+)$'  # exclude these tags

ECR — lifecycle policy in the console is the cleanest option. Write rules like “delete untagged images after 7 days” or “keep only 30 of a particular tag prefix.”

Docker Hub — public is unlimited. Private is one for free, so you almost always have to be conscious of it.

The dangerous thing to miss when designing retention rules — protect immutable tags (sha-..., vX.Y.Z). If the production manifest pins a sha tag and that tag gets cleaned up, rollback is gone. The ignore-versions in the workflow above is exactly that protection.

Reducing image size itself — recap #

Worth tidying up once even apart from tag strategy. Recapping the patterns from Intermediate #1:

  • alpine base — python:3.14-slim (~50MB) vs python:3.14-alpine (~25MB). But the glibc → musl difference means some packages won’t install. FastAPI/Django often gets painful on alpine (build dependencies); Node tends to be fine on alpine.
  • distroless — Google’s baseless images. gcr.io/distroless/python3-debian12. No shell, so debugging is hard, but the attack surface is the smallest.
  • multi-stage — keeps devDeps/build tools out of the final image.
  • .dockerignore — shrinks the build context itself.

Checking size after build:

Image size / layer analysis
docker images ghcr.io/me/app
docker history ghcr.io/me/app:sha-a1b2c3d --no-trunc
# how much each layer takes, line by line

If you want layer-by-layer analysis, dive is great.

dive — visual analysis
brew install dive
dive ghcr.io/me/app:sha-a1b2c3d

Per-environment images — :dev vs :prod #

The issue from #3 also shows up here — when env vars are baked in at build time (Next.js), you end up with different images per environment. Splitting the tag with an env prefix is clean.

Per-environment images
ghcr.io/me/app:prod-sha-a1b2c3d     ← production build (NEXT_PUBLIC_API_URL=prod)
ghcr.io/me/app:stage-sha-a1b2c3d    ← staging build (NEXT_PUBLIC_API_URL=stage)

The pattern for running per-env builds in CI.

Per-environment build
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [stage, prod]
        include:
          - env: stage
            api_url: https://api.stage.example.com
          - env: prod
            api_url: https://api.example.com
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          flavor: |
            prefix=${{ matrix.env }}-,onlatest=true
          tags: |
            type=sha,prefix=sha-,format=short
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          build-args: |
            NEXT_PUBLIC_API_URL=${{ matrix.api_url }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha,scope=${{ matrix.env }}
          cache-to: type=gha,mode=max,scope=${{ matrix.env }}

A backend (FastAPI/Django) where only the server-side env vars differ can use one image for all environments — this split isn’t needed.

Common traps #

:latest is somehow an old image — caching. Clear node cache or adjust imagePullPolicy. Wouldn’t have run into it if you’d gone with sha tags from the start.

One PR’s image overwrites another’s — just use metadata-action’s type=ref,event=pr and you get clean separation like pr-123. It usually only goes wrong when people roll their own.

10,000 untagged images — happens when you don’t have automatic cleanup like delete-only-untagged-versions: true. Setting up retention policy once makes it disappear.

ECR pull suddenly refused — if it’s not an IAM permissions issue, the image was probably cleaned up by retention. Re-check the lifecycle policy.

Docker Hub pull rate limit (in CI) — anonymous pull limit. Mirror base images to GHCR, or log in with a Docker Hub account to lift the limit.

Summary #

  • For production deploys, pinning a SHA tag (sha-a1b2c3d) in the image: field is the standard. Immutable, traceable, simple rollback.
  • Never use :latest in production. What it points to changes over time, breaking caching, rollback, and consistency.
  • Attaching multiple tags simultaneously to the same image is the standard pattern. metadata-action automates it.
  • Use auto-updating semver tags (1.4, 1) only when the intent is “give me the latest patch.” Use exact versions in production.
  • Registry: GHCR for GitHub, ECR for AWS, Docker Hub for OSS. That’s the rough skeleton.
  • Set up retention policy once before going live and it stays easy. Keep sha-* / vX.Y.Z in the protect rules.
  • Image size: start with multi-stage + .dockerignore. alpine/distroless is the next step.

The next post (#6 Cloud deployment) is the last in the track — taking the images you’ve built and tagged this way and putting them on actual clouds. The fork between Fly.io / Railway / ECS and the flow for each.

X