Docker in Practice #5 Pushing to Registries and Tag Strategy — The :latest Trap
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:
- #1 Containerizing FastAPI
- #2 Django + PostgreSQL with compose
- #3 React/Next.js build container
- #4 Building images in CI
- #5 Pushing to registries and tag strategy — the :latest trap ← this post
- #6 Cloud deployment — Fly.io / Railway / ECS
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.
| Registry | Where it fits | Free tier | Auth |
|---|---|---|---|
| Docker Hub | The oldest, the public standard | 1 private, pull rate limits | Account + PAT |
| GHCR (GitHub Container Registry) | Naturally tied to your GitHub repo | Effectively unlimited (public/private) | GITHUB_TOKEN |
| AWS ECR | When you want it inside AWS infra | 500MB free / GB-priced after | IAM |
| Google Artifact Registry | The GCP-side standard | 0.5GB free / priced after | gcloud / WIF |
| Private registry | Regulated environments | — | Self-managed |
A one-line decision rule:
- If you’re hosting code on GitHub, use GHCR. No separate auth setup —
GITHUB_TOKENjust 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.
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 imageWhat 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.”
docker pull nginx # actually docker pull nginx:latest
docker run myapp # actually docker run myapp:latestUsing :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.
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 theimage: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 like1.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.
- 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.
- 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
fiWhich tag belongs in production — the conclusion #
In production deploy manifests (e.g., ECS task definitions, Kubernetes Deployments, compose) the image: field should be:
image: ghcr.io/me/app:sha-a1b2c3dPinning 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.
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 tagsECR — 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) vspython: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:
docker images ghcr.io/me/app
docker history ghcr.io/me/app:sha-a1b2c3d --no-trunc
# how much each layer takes, line by lineIf you want layer-by-layer analysis, dive is great.
brew install dive
dive ghcr.io/me/app:sha-a1b2c3dPer-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.
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.
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 theimage:field is the standard. Immutable, traceable, simple rollback. - Never use
:latestin 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-actionautomates 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.Zin 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.