Stage 4 · Production · Chapter 19 of 20 · 5 min read

Registries and CI: tag, push and ship

An image on your laptop helps nobody else. Tag it with a registry address, push it, and let CI build every release the same way.

? What do the diagram colours mean

key idea / Docker itselfcontainer / good pathhost / watch outproblem / blockedneutral

What is in an image name

Chapter 1 drew a registry; this is where you use one. The name of an image is also its address.

Anatomy of an image nameThe image name ghcr.io/acme/shop-api:1.4.2 has four parts: the registry host ghcr.io, the namespace acme, the repository shop-api and the tag 1.4.2. A digest after @ identifies the exact content.ghcr.ioregistry host/acmenamespace / owner/shop-apirepository:1.4.2tag (movable label)No host means Docker Hub: nginx:1.27 is short for docker.io/library/nginx:1.27.A digest (@sha256:…) names the exact bytes and never moves. A tag can be re-pointed any time.

Tag, push, pull

From your laptop to a serverYou build and tag an image, log in to a registry and push it. The server logs in to the same registry and pulls the image by tag or digest, then runs it.docker buildshop-api:devdocker tagadds registry namedocker pushupload layersRegistrylayers + tagsdocker pullon the serveronly new layers move
Layers are shared across tags and images, so a push after a one-line change uploads one small layer.
$ docker build -t shop-api:dev .
$ docker tag shop-api:dev ghcr.io/acme/shop-api:1.4.2
$ docker login ghcr.io                 # a token, never your password
$ docker push ghcr.io/acme/shop-api:1.4.2

# on the server
$ docker pull ghcr.io/acme/shop-api:1.4.2
RegistryHostTypical use
Docker Hubdocker.iopublic images, the default
GitHub Container Registryghcr.ioimages next to the code that builds them
Cloud registriesECR, Artifact Registry, ACRprivate images close to where they run
Your ownregistry:2 imageair-gapped networks, experiments

A tagging strategy that survives production

Apple silicon and Intel servers

An image is built for one CPU architecture. A Mac with Apple silicon builds arm64 by default; most servers are amd64. docker buildx can build both into one multi-platform tag, and each machine pulls the variant it needs.

$ docker buildx build --platform linux/amd64,linux/arm64 \
    -t ghcr.io/acme/shop-api:1.4.2 --push .

Build and push in CI

Images for servers should come from CI, not from a laptop: the build is repeatable, the credentials stay in the CI system, and every image is tied to a commit. This GitHub Actions workflow builds on every push to main and every version tag, for both architectures, with the layer cache kept between runs.

# .github/workflows/image.yml
name: image
on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write          # allowed to push to ghcr.io
    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 }}
      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=semver,pattern={{version}}
      - uses: docker/build-push-action@v6
        with:
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Add a scan step (chapter 18) before the push to fail the build on serious vulnerabilities, and run your tests inside the image you are about to ship.

Try it yourself

  1. Step 1 · Run a registry of your own

    The official registry:2 image is a complete registry. Bound to 127.0.0.1, only your machine can reach it.

    $ docker run -d --name registry --rm -p 127.0.0.1:5000:5000 registry:2
  2. Step 2 · Tag and push

    The tag starts with the registry host, so push knows where to send it.

    $ docker pull alpine:3.20
    $ docker tag alpine:3.20 localhost:5000/tools/alpine:3.20
    $ docker push localhost:5000/tools/alpine:3.20
    The push refers to repository [localhost:5000/tools/alpine]
    3.20: digest: sha256:… size: 528
  3. Step 3 · Ask the registry what it has

    A registry is an HTTP API.

    $ curl -s localhost:5000/v2/_catalog
    {"repositories":["tools/alpine"]}
    $ curl -s localhost:5000/v2/tools/alpine/tags/list
    {"name":"tools/alpine","tags":["3.20"]}
  4. Step 4 · Delete locally, pull back
    $ docker rmi localhost:5000/tools/alpine:3.20
    $ docker pull localhost:5000/tools/alpine:3.20
    3.20: Pulling from tools/alpine
    Status: Downloaded newer image for localhost:5000/tools/alpine:3.20
    $ docker stop registry
Common mistakeBuilding release images on an Apple-silicon Mac. It builds arm64 by default, and an amd64 server then fails with “exec format error”. Build in CI, or pass --platform.

Recap

Quick check

docker push myapp:1.0 fails with “requested access to the resource is denied”. Most likely?

Show answer

Answer: myapp means docker.io/library/myapp, which you cannot write to.

Tag it into a namespace you own, such as you/myapp:1.0 or ghcr.io/you/myapp:1.0, and log in.

Two servers run myapp:latest but behave differently. What is the fix?

Show answer

Answer: Deploy a unique tag (or a digest) per build.

latest moves. Tag each build with its commit or version, or pin @sha256, so every server runs the same bytes.

Registries and CI: common questions

Why does docker push say access denied?

An image name without a registry or namespace points at docker.io/library, which you cannot write to. Tag it as yourname/app or ghcr.io/you/app and log in first.