You have a professional image on your machine: auroralibros/aurora-api:1.1.0, with an unprivileged user, OCI labels, a healthcheck and a clean 0.3-second shutdown. And you know how to maintain the store it lives in. It is missing the one thing that justified the whole module: leaving your laptop. An image that only exists on the machine where it was built does not solve the Aurora Libros problem; it solves your local problem, which is not the same thing. This lesson closes the cycle. You are going to understand what docker image tag does exactly — which is not copying — you are going to decide a tagging strategy that will not blow up on you six months from now, you are going to publish to Docker Hub and also to GitHub Container Registry, and you are going to learn why production deploys by immutable tag or by digest and never, ever by latest. By the end, anyone in the world with Docker installed will be able to run the Aurora Libros API with a single command.
Contents
docker image tag: references, not copies- The full name and the implicit registry
- Tagging strategy
latestand why you do not deploy with it- Publishing to Docker Hub with
docker push - Verifying what you published
- Publishing to GitHub Container Registry
- Private repositories and team access
- Deleting tags and why it breaks deployments
- Naming conventions for Aurora Libros
docker image tag: references, not copies
docker image tag: references, not copiesLet's start by undoing the most widespread misunderstanding. docker image tag does not copy an image, does not duplicate layers and does not take up any extra space. It creates a new name that points at the same image.
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:1.1
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:1
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:latest
docker image ls auroralibros/aurora-api --format "table {{.Tag}}\t{{.ID}}\t{{.Size}}"TAG IMAGE ID SIZE
1 4e9c7d2a8f31 167MB
1.1 4e9c7d2a8f31 167MB
1.1.0 4e9c7d2a8f31 167MB
latest 4e9c7d2a8f31 167MBFour rows, a single IMAGE ID. They are four names for the same image. And the disk confirms it:
Before creating the three tags it was 842.3 MB; afterwards, exactly the same 842.3 MB. It is the same demonstration you did in exercise 2 of the previous lesson, and the same model as section 1 of 02-01: tags are pointers to digests, not containers of data.
The syntax:
The source can be an existing name or an IMAGE ID:
docker image tag 4e9c7d2a8f31 auroralibros/aurora-api:candidate
docker image tag auroralibros/aurora-api:1.1.0 ghcr.io/auroralibros/aurora-api:1.1.0
docker image tag auroralibros/aurora-api:1.1.0 registry.internal.auroralibros.example/aurora-api:1.1.0All three are free and instantaneous. The practical consequence is important: you can publish the same image to several registries without rebuilding it. You will do that in section 7.
Rules for tag names:
| Rule | Valid | Not valid |
|---|---|---|
| Up to 128 characters | 1.1.0-rc.1 |
A 200-character string |
Letters, digits, _, ., - |
v1.1.0, main_2026-08-04 |
1.1.0/rc, version&1 |
Cannot start with . or - |
1.1.0 |
.hidden, -beta |
| Uppercase allowed in the tag | 1.1.0-RC1 |
— |
| The repository name goes in lowercase | aurora-api |
Aurora-API |
The last row causes real errors: docker push AuroraLibros/Aurora-API fails with invalid reference format: repository name must be lowercase.
- The full name and the implicit registry
You already saw this in lesson 02-01, but now is when it really matters, because the name determines where the push goes:
Docker expands that name to docker.io/auroralibros/aurora-api:1.1.0 and sends the image to Docker Hub. There is no --registry option: if you want to publish somewhere else, you change the name.
| Name | Push destination |
|---|---|
aurora-api:1.1.0 |
docker.io/library/aurora-api → fails: nobody can write to library/ |
auroralibros/aurora-api:1.1.0 |
Docker Hub, the auroralibros namespace |
ghcr.io/auroralibros/aurora-api:1.1.0 |
GitHub Container Registry |
localhost:5000/aurora-api:1.1.0 |
Your local registry from lesson 02-01 |
123456789012.dkr.ecr.eu-west-1.amazonaws.com/aurora-api:1.1.0 |
Amazon ECR |
The first row is the denied: requested access to the resource is denied error you diagnosed in exercise 3 of lesson 02-01. You now know that it is almost never a credentials problem: it is a name with no namespace.
Docker tells the registry apart from the user with a simple heuristic: if the first segment contains a dot or a colon, or is exactly localhost, it treats it as a server name. That is why ghcr.io/… is a registry and auroralibros/… is a Docker Hub user.
- Tagging strategy
Here is the decision that prevents the most problems in the medium term. Tagging badly breaks nothing today; it breaks everything six months from now, when nobody knows which version is in production.
Semantic versioning: fixed and moving tags
Semantic versioning (MAJOR.MINOR.PATCH) gives you three numbers with meaning:
| Component | When it goes up | Example in Aurora Libros |
|---|---|---|
| MAJOR | An incompatible change in the API | /books changes its response format |
| MINOR | New, compatible functionality | /books/search is added |
| PATCH | A compatible fix | The VAT-inclusive price calculation is fixed |
And from that come two classes of tag that must be told apart clearly:
| Class | Examples | Does the image it points to change? | What it is for |
|---|---|---|---|
| Fixed (immutable) | 1.1.0, 1.1.0-rc.1, sha-7a3f912 |
Never | Deploying, reproducing, auditing |
| Moving | 1.1, 1, latest, stable |
Yes, on every publish | Convenience, following a series |
Publishing a typical Aurora Libros version:
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:1.1
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:1
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:latestWith this, whoever consumes the image chooses their own level of risk:
flowchart LR
subgraph TODAY["Publishing 1.1.0"]
T1["1.1.0"] --> IMG1["sha256:4e9c…"]
T2["1.1"] --> IMG1
T3["1"] --> IMG1
T4["latest"] --> IMG1
end
subgraph TOMORROW["Publishing 1.1.1 (patch)"]
U1["1.1.0"] --> IMG1b["sha256:4e9c…<br/>UNTOUCHED"]
U2["1.1.1"] --> IMG2["sha256:8b3f…"]
U3["1.1"] --> IMG2
U4["1"] --> IMG2
U5["latest"] --> IMG2
end
TODAY --> TOMORROW
Notice the essential point of the diagram: when 1.1.1 is published, the 1.1.0 tag still points to exactly the same image as always. The moving ones have shifted. Whoever deployed 1.1.0 is not affected by anything; whoever uses 1.1 gets the patch automatically.
Other strategies
| Strategy | Example | Advantages | Drawbacks |
|---|---|---|---|
| Fixed SemVer | 1.1.0 |
Traceable, reproducible, the standard | You have to decide the number for each release |
| Moving SemVer | 1.1, 1 |
Automatic patches without touching the deployment | You do not know which exact image is running |
| Commit SHA | sha-7a3f912 |
Perfect traceability to the code; automatic in CI | Unreadable for humans |
| By branch | main, develop, feature-search |
Convenient in development | Moving by definition; never in production |
| By environment | production, staging |
Simple to understand | Antipattern: it hides which version is deployed |
| By date | 2026-08-04, 20260804-1142 |
Obvious chronological order | Says nothing about the content |
latest |
latest |
Convenient when testing | Never in production (section 4) |
The by-environment strategy deserves a special warning, because it looks reasonable and it is a trap. If you deploy auroralibros/aurora-api:production, the question "which version is in production?" has no answer: the tag has moved twenty times and nobody knows what it points to today. And a rollback is impossible, because the previous image lost its name. The right way round is the opposite: the image is tagged by version, and which version goes to each environment is decided by the deployment file, versioned in Git.
The recommended combination
In practice, a mature pipeline publishes several tags of the same image on each release:
VERSION=1.1.0
COMMIT=$(git rev-parse --short HEAD)
IMAGE=auroralibros/aurora-api
docker build -t $IMAGE:$VERSION \
-t $IMAGE:sha-$COMMIT \
-t $IMAGE:1.1 \
-t $IMAGE:1 \
-t $IMAGE:latest \
--build-arg VERSION=$VERSION \
--build-arg REVISION=$COMMIT \
--build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" .Remember from lesson 02-02 that -t is repeatable: one build, five names, zero extra cost. The --build-args feed the OCI labels from lesson 02-04, so the image carries inside the same information it declares outside. That consistency is what lets you answer "what code is in this?" with a single docker inspect.
latest and why you do not deploy with it
latest and why you do not deploy with itlatest has no special meaning for Docker. It is not "the most recent", it does not update itself and nobody computes it: it is simply the tag used by default when you do not write one. If nobody publishes a tag called latest, it does not exist.
The problems with deploying using it:
- It is not reproducible.
docker run auroralibros/aurora-api:latesttoday and tomorrow may run different code. If it fails tomorrow, you cannot reproduce today's state. - It breaks rollbacks. "Go back to the previous version" makes no sense if the only reference is
latest. - It can lie. If you publish
1.2.0and then carelessly publish a1.1.1patch from the old branch,latestcan end up pointing at the older version. It is a manual tag, not a computation. - It causes heterogeneous deployments. Five replicas started at different times with
latestmay be running three different versions at once. - It makes auditing impossible. What code is in production? "Latest." That is not an answer.
The golden rule: production deploys by immutable tag or by digest. Never by latest, nor by any moving tag.
Deploying by digest
The highest level of guarantee is referencing the manifest's digest:
docker pull auroralibros/aurora-api:1.1.0
docker image ls --digests auroralibros/aurora-api --format "{{.Tag}}\t{{.Digest}}"And this is how you deploy it:
docker pull auroralibros/aurora-api@sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3
docker run -d --name aurora-api \
auroralibros/aurora-api@sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3A comparison of the guarantees:
| Reference | Reproducible? | Readable | Use |
|---|---|---|---|
:latest |
No | Yes | Quick tests and nothing else |
:1.1 |
No (it moves with patches) | Yes | Development |
:1.1.0 |
Yes by convention (nothing stops it being overwritten) | Yes | Normal production |
@sha256:c4f8… |
Yes, cryptographically | No | Critical production, audited supply chain |
The 1.1.0 row has a nuance worth understanding: nothing in Docker stops somebody from republishing 1.1.0 pointing at a different image. It is a team convention, not a technical guarantee. The digest is one: sha256:c4f8… is the hash of the manifest, and if the content changed, the hash would be different. That is why serious deployment systems (Kubernetes in regulated environments, lesson 06-05) reference by digest.
You can get a published image's digest without downloading it:
- Publishing to Docker Hub with
docker push
docker pushThe moment of truth. Remember from section 10 of lesson 02-01 that the chosen repository is auroralibros/aurora-api, public.
Step 1: authenticate
In the password field, paste the personal access token with Read & Write permission, not your password, for all the reasons explained in lesson 02-01.
Step 2: check that the name is right
If your real Docker ID is not auroralibros, retag first:
Step 3: audit before publishing
This step is not optional. A public image is published code, and once it is uploaded there is no real going back: somebody may have downloaded it a minute later.
# a) Is there any secret in the environment variables?
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{range .Config.Env}}{{println .}}{{end}}'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NODE_VERSION=22.14.0
YARN_VERSION=1.22.22
NODE_ENV=production
PORT=3000
APP_VERSION=1.1.0No credentials. Correct.
# b) Is there any --build-arg with secrets in the history?
docker image history auroralibros/aurora-api:1.1.0 --no-trunc --format "{{.CreatedBy}}" | grep -iE "password|secret|token|key" || echo "No secrets in the history"# c) Did any file sneak in that should not have?
docker run --rm --entrypoint ls auroralibros/aurora-api:1.1.0 -la /appdrwxr-xr-x 1 node node 4096 Aug 4 11:42 .
-rw-r--r-- 1 node node 412 Aug 4 09:15 Dockerfile
drwxr-xr-x 112 node node 4096 Aug 4 11:42 node_modules
-rw-r--r-- 1 node node 44231 Aug 4 09:15 package-lock.json
-rw-r--r-- 1 node node 412 Aug 4 09:15 package.json
-rw-r--r-- 1 node node 6104 Aug 4 11:20 server.jsNo .env, no .git, no local notes. The .dockerignore from lesson 02-02 has done its job. (The Dockerfile shows up if you removed it from the exclusion list; it is not a security problem, but it is worth keeping it excluded for the reasons explained there.)
Step 4: the push
The push refers to repository [docker.io/auroralibros/aurora-api]
9c1e4a7f2b9d: Pushed
7d3c9f2e8a51: Pushed
2f8e1a9c4b73: Pushed
b8f4e2a91c37: Mounted from library/node
4a1b8c2f9e3d: Mounted from library/node
8f3e1d7c5b9a: Mounted from library/node
e5c2a8f14b76: Mounted from library/node
1.1.0: digest: sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3 size: 1785This output tells a story, and it is worth reading calmly:
| Line | What it means |
|---|---|
Pushed |
A layer actually uploaded. These are your three: the two COPYs and the RUN npm ci |
Mounted from library/node |
A layer that was already in the registry (it belongs to node:22-alpine) and has been referenced, not transferred |
1.1.0: digest: sha256:… |
The manifest's digest, your immutable identifier |
size: 1785 |
The size of the manifest in bytes, not of the image |
The four Mounted from lines are the shared-layer efficiency from lesson 01-05 taken to the registry: of the image's 167 MB, you only uploaded about 25. The rest already lived there because it is part of the official Node image. That is why an image built properly on a common base publishes in seconds.
Now the remaining tags:
docker push auroralibros/aurora-api:1.1
docker push auroralibros/aurora-api:1
docker push auroralibros/aurora-api:latestThe push refers to repository [docker.io/auroralibros/aurora-api]
9c1e4a7f2b9d: Layer already exists
7d3c9f2e8a51: Layer already exists
2f8e1a9c4b73: Layer already exists
b8f4e2a91c37: Layer already exists
...
1.1: digest: sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3 size: 1785All Layer already exists, and the same digest as 1.1.0. It is the confirmation that tags are pointers: no data has been uploaded, only three new names have been created in the registry. All three publishes together take less than a second.
A shortcut worth knowing and using carefully:
It uploads every local tag of that repository. Convenient, but dangerous: if you have a local test tag (experiment, debug), it will end up published. Better to push explicitly what you want to publish.
- Verifying what you published
Never assume a publish worked without checking it.
On the website
Go to https://hub.docker.com/r/auroralibros/aurora-api and open the Tags tab. You should see 1.1.0, 1.1, 1 and latest, all with the same compressed size and the same date. It is the same tab you learned to read in lesson 02-01, now from the other side.
With docker manifest inspect
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 3421,
"digest": "sha256:4e9c7d2a8f31b5c9e2f7a1d4c8b3e6f9a2d5c8b1e4f7a3d6c9b2e5f8a1d4c7b0"
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 3622890,
"digest": "sha256:e5c2a8f14b76..."
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 24718432,
"digest": "sha256:2f8e1a9c4b73..."
}
]
}This command queries the remote registry, not your local store: it is the definitive proof that the image is there. What it tells you:
mediaType: the manifest's format (OCI, the standard from lesson 01-03).config.digest: the identifier of the image's configuration.layers: each layer with its compressed size and its digest. There you can see that thenpm cilayer is 24.7 MB, matching whatdocker image historyreported in lesson 02-05.
Verify as well that two tags point at the same thing:
docker manifest inspect auroralibros/aurora-api:1.1.0 | sha256sum
docker manifest inspect auroralibros/aurora-api:latest | sha256sumb8e2f4a91c37d5e8a2f6c9b1d4e7a3f8c5b2e9d6a1f4c7b0e3a6d9f2c5b8e1a4 -
b8e2f4a91c37d5e8a2f6c9b1d4e7a3f8c5b2e9d6a1f4c7b0e3a6d9f2c5b8e1a4 -Identical manifests: 1.1.0 and latest are the same image.
The real test: running it from scratch
This is the one that closes the module. Delete the local image and pull it from the registry as anybody in the world would:
docker image rm auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:1.1 \
auroralibros/aurora-api:1 auroralibros/aurora-api:latest
docker image ls auroralibros/aurora-apiEmpty. Now, from the registry:
Unable to find image 'auroralibros/aurora-api:1.1.0' locally
1.1.0: Pulling from auroralibros/aurora-api
e5c2a8f14b76: Pull complete
2f8e1a9c4b73: Pull complete
...
Status: Downloaded newer image for auroralibros/aurora-api:1.1.0
a7f3c9e2b8d1...curl -s http://localhost:3000/health
docker ps --filter name=aurora-published --format "{{.Names}}: {{.Status}}"{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",
"errorDb":"connect ECONNREFUSED 127.0.0.1:5432","errorCache":"connect ECONNREFUSED 127.0.0.1:6379"}The Aurora Libros API is running from an image downloaded off the internet. That docker run works right now on any machine on the planet with Docker installed: without installing Node, without nvm, without npm install, without knowing the project. Compare that with the seven attempts and the hour and a half from lesson 01-07.
- Publishing to GitHub Container Registry
Publishing to a second registry is common: redundancy, proximity to the code, different pull limits. And with what you know, it takes three commands.
Step 1: create a GitHub token
In GitHub: Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token, with these scopes:
| Scope | What for |
|---|---|
write:packages |
Publishing images |
read:packages |
Pulling them |
delete:packages |
Deleting versions (section 9) |
Copy it: it is not shown again.
Step 2: authenticate
--password-stdin for the reasons explained in lesson 02-01: no passwords in the shell history. And notice that you now have two sessions open at once, Docker Hub and ghcr.io, with neither interfering with the other. The destination is decided by the image's name, not by any global state.
Two entries, both empty because the credential helper stores the credentials in the system keyring.
Step 3: retag and publish
docker pull auroralibros/aurora-api:1.1.0 # If you deleted it in section 6
docker image tag auroralibros/aurora-api:1.1.0 ghcr.io/auroralibros/aurora-api:1.1.0
docker image tag auroralibros/aurora-api:1.1.0 ghcr.io/auroralibros/aurora-api:latest
docker push ghcr.io/auroralibros/aurora-api:1.1.0
docker push ghcr.io/auroralibros/aurora-api:latestThe push refers to repository [ghcr.io/auroralibros/aurora-api]
9c1e4a7f2b9d: Pushed
7d3c9f2e8a51: Pushed
2f8e1a9c4b73: Pushed
b8f4e2a91c37: Pushed
4a1b8c2f9e3d: Pushed
8f3e1d7c5b9a: Pushed
e5c2a8f14b76: Pushed
1.1.0: digest: sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3 size: 1785Two important observations:
- Every layer says
Pushed, none saysMounted from. That makes sense:ghcr.iohad no previous copy of thenode:22-alpinelayers, so they have all been uploaded in full. The first publish to a new registry is always the slow one. - The digest is identical to Docker Hub's:
sha256:c4f81b2e…. The image is exactly the same in both registries, bit for bit. The digest depends on the content, not on where it is hosted.
In GitHub, the package appears at https://github.com/users/auroralibros/packages. One detail: by default, ghcr.io packages are private. To make it public you have to go to Package settings → Change visibility → Public. And to link it to the code repository, all you need is for the org.opencontainers.image.source OCI label from lesson 02-04 to point at the repository: GitHub reads it and connects the two automatically. That metadata work starts paying off here.
A comparison of the two registries for Aurora Libros:
| Aspect | Docker Hub | GitHub Container Registry |
|---|---|---|
| Private repositories on the free plan | Limited allowance | Unlimited |
| Anonymous pull limit | Yes (lesson 02-01) | Much more relaxed |
| Integration with the code | Manual | Automatic via the source OCI label |
| Public discoverability | Maximum | Lower |
| Permissions | Per organization | Inherited from the GitHub repository |
For Aurora Libros, the sensible policy: Docker Hub as the public registry (discoverable, it is where people search) and ghcr.io as the team's working registry, integrated with the code and with private repos at no cost.
- Private repositories and team access
Aurora Libros is a company: its real API should not be public. Here is how you change that.
Making a Docker Hub repository private
- Go to the repository → Settings tab.
- Visibility settings → Make private.
- Confirm by typing the repository's name.
The effect is immediate:
Error response from daemon: pull access denied for auroralibros/aurora-api,
repository does not exist or may require 'docker login': denied: requested access
to the resource is deniedIt is the ambiguous message from exercise 3 of lesson 02-01, now with the cause that was missing from that list: the repository exists, but without a session you cannot even know that. The registry deliberately does not distinguish between "it does not exist" and "you do not have permission", so as not to leak which private repositories an organization has.
Granting access to a team
A private repository under a personal account is only visible to its owner. For a team you need an organization:
- Docker Hub → Organizations → Create Organization (for example,
auroralibros). - Inside it, Teams → create teams by function:
| Team | Permission | Who |
|---|---|---|
development |
Read | Developers: they pull to work locally |
ci |
Read & Write | The pipeline's service account: it builds and publishes |
platform |
Admin | Owners: visibility, deletions, configuration |
- Add members to each team.
- In the repository → Permissions → assign each team its level.
Two principles that have to be respected:
- Least privilege. A developer does not need
write. If nobody but the pipeline publishes, nobody but the pipeline should be able to. - Service accounts for automation. The pipeline never uses anyone's personal credentials: it uses an account of its own with a limited-scope token. If that person leaves the company, the pipeline does not go down.
In GitHub Container Registry it is simpler because it inherits from the code repository: whoever has read access to the repo has access to the package, and it is tuned in Package settings → Manage Actions access.
- Deleting tags and why it breaks deployments
You can delete a published tag. You almost never should.
On Docker Hub: repository → Tags tab → select → Delete. To delete the whole repository, Settings → Delete repository.
On ghcr.io: the package's page → Package settings → Manage versions → Delete.
Why it is dangerous
A published tag is somebody else's dependency. Think about what happens when you delete auroralibros/aurora-api:1.1.0:
- Existing deployments fail on restart. Running containers stay alive (they have the image locally), but any new machine, any replica that scales up and any restart after a
docker system prunegives:
Error response from daemon: manifest for auroralibros/aurora-api:1.1.0 not found:
manifest unknown: manifest unknown- Rollbacks become impossible. The previous version was exactly what you needed to go back to.
- Pipelines break. Any Dockerfile that did
FROM auroralibros/aurora-api:1.1.0stops building. - Traceability disappears. You cannot reproduce an incident from three months ago.
And an even worse variant: overwriting a published tag, that is, pushing a different image under the same 1.1.0. Nobody notices, the machines that already have it stay on the old one, new ones pull the new one, and you end up with a deployment where 1.1.0 means two different things depending on when each replica started. It is one of the hardest incidents there is to diagnose.
When you should delete
| Situation | Action |
|---|---|
| A secret was published by mistake | Delete immediately and, above all, rotate the credential: you have to assume it is already compromised |
Old development tags (pr-142, sha-… from months ago) |
Routine deletion, with retention policies |
| A version with a serious security flaw | Better to publish a fixed 1.1.1 and mark the previous one as obsolete in the description than to delete it |
| A stable version in use | Never |
The correct alternative to deletion is deprecation: publish a new version, document in the repository description that the previous one should not be used and give a deadline. You warn people, you do not break them.
And a warning about secrets: deleting the tag does not delete the problem. If you published a password inside an image, anyone could have downloaded it in the minutes it was available. The only valid response is rotating that credential. That is why step 3 of section 5 — auditing before publishing — is not bureaucracy.
- Naming conventions for Aurora Libros
We close with the decision written down, which is what a real team agrees on and documents in its repository.
Repositories
One repository per deployable artifact, never one per project:
| Service | Repository | Public registry | Team registry |
|---|---|---|---|
| Catalog API | aurora-api |
docker.io/auroralibros/aurora-api |
ghcr.io/auroralibros/aurora-api |
| Web and proxy | aurora-web |
docker.io/auroralibros/aurora-web |
ghcr.io/auroralibros/aurora-web |
| Database | (not applicable) | The official postgres:16-alpine is used |
— |
| Cache | (not applicable) | The official redis:7-alpine is used |
— |
The last two rows are a deliberate decision: official images are not repackaged without a reason. aurora-db will use postgres:16-alpine as it is, with its initialization mounted from outside (module 3). Creating an image of your own just to put an init.sql inside adds an image to maintain in exchange for nothing.
Tags
| Tag | When it is published | Moving? | Permitted use |
|---|---|---|---|
1.1.0 |
On each release, from a Git tag | No | Production |
1.1 |
With each patch in the 1.1 series | Yes | Development, test environments |
1 |
With each minor version in the 1 series | Yes | Development |
latest |
With the latest stable version | Yes | Quick tests. Never in production |
sha-7a3f912 |
On every commit to main, from CI |
No | Debugging, traceability, fine-grained rollback |
1.2.0-rc.1 |
Release candidates | No | Pre-production environment |
pr-142 |
On each pull request | Yes | Review; deleted when the PR is closed |
The agreed rules
- Production deploys by immutable tag (
1.1.0) or by digest. Never bylatest,1.1or1. - A published fixed tag is never overwritten. If there is a bug, a new version is published.
- Every image carries the OCI labels from lesson 02-04, with
versionandrevisionmatching the registry's tags. - Only the CI service account publishes to production. People publish, at most,
pr-*andsha-*tags. - Audit before publishing: environment variables, history and the content of
/app. pr-*tags expire after 30 days, with an automatic retention policy.
An example of the complete release command, which is what the pipeline in lesson 06-02 will run:
#!/bin/bash
# publish-release.sh — publishes a version of aurora-api to both registries
set -euo pipefail
VERSION="${1:?Usage: publish-release.sh <version> e.g.: 1.1.0}"
COMMIT=$(git rev-parse --short HEAD)
CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)
MAJOR="${VERSION%%.*}"
MINOR="${VERSION%.*}"
for REG in "auroralibros" "ghcr.io/auroralibros"; do
IMG="$REG/aurora-api"
docker build \
-t "$IMG:$VERSION" \
-t "$IMG:$MINOR" \
-t "$IMG:$MAJOR" \
-t "$IMG:latest" \
-t "$IMG:sha-$COMMIT" \
--build-arg VERSION="$VERSION" \
--build-arg REVISION="$COMMIT" \
--build-arg CREATED="$CREATED" \
--pull \
./api
for T in "$VERSION" "$MINOR" "$MAJOR" "latest" "sha-$COMMIT"; do
docker push "$IMG:$T"
done
done
echo "Published $VERSION (commit $COMMIT) to Docker Hub and ghcr.io"
docker manifest inspect "auroralibros/aurora-api:$VERSION" --verbose | grep -m1 digestNotice the details: set -euo pipefail aborts on any error, ${VERSION%%.*} and ${VERSION%.*} derive 1 and 1.1 from 1.1.0 using shell parameter expansion, --pull guarantees an up-to-date base as explained in 02-02, and the --build-args keep the internal OCI labels consistent with the registry's tags.
Common Mistakes and Tips
- Believing that
docker image tagcopies the image. It creates a reference; the disk does not grow. Four tags of the same image take up what one does. docker push aurora-api:1.1.0with no namespace. It expands tolibrary/aurora-apiand fails withdenied. Faced with adeniedon a push, look at the name before the credentials.- Uppercase in the repository name.
invalid reference format: repository name must be lowercase. Only the tag accepts uppercase. - Deploying with
latest. It is not reproducible, it prevents rollbacks, it can point to an older version and it makes it impossible to audit what is in production. - Tagging by environment (
production,staging). An antipattern: the tag moves and nobody knows which version is running. Tag by version and decide at deployment time. - Overwriting an already-published fixed tag. It causes deployments where
1.1.0means different things depending on when each replica started. Publish a new version. - Deleting a published tag. It breaks restarts, scale-ups and rollbacks for everyone using it. Deprecate instead of deleting.
- Publishing without auditing. A published secret has to be treated as compromised: deleting the tag is not enough, you have to rotate the credential.
- Using
docker push --all-tagslightly. It publishes your local test tags too. - Using your password instead of a token. A token can be revoked without changing your password, it has limited permissions and you can have one per machine.
- Tip: always verify with
docker manifest inspect. It queries the remote registry, not your local cache: it is the only real proof that the publish worked. - Tip: delete the local image and pull it from the registry before signing off on a release. It is the only way to check that what you published is self-sufficient.
- Tip: make the internal OCI labels match the registry's tags. Having
org.opencontainers.image.versionsay1.1.0while the tag is1.1.0will save you more than one investigation.
Exercises
Exercise 1: prove that tags are pointers
- Note the result of
docker system df(the Images line). - Create five new tags for
auroralibros/aurora-api:1.1.0:1.1,1,latest,stableandsha-abc1234. - Run
docker system dfagain. Has the space grown? Why? - Check that the six references share an IMAGE ID.
- Delete the
stableandsha-abc1234tags. What message does each deletion give? And what if you deleted all six? - Rebuild the image after modifying
server.js, tagging it again as1.1.0. What happens to1.1,1andlatest? Explain the result in terms of pointers.
Exercise 2: publish and verify the complete cycle
With your real Docker Hub account (replace auroralibros with your Docker ID):
- Audit the image before publishing: environment variables, history in search of secrets and the content of
/app. - Publish
1.1.0andlatest, and analyze the output of the first push: how many layers sayPushedand how manyMounted from? Why? - Analyze the output of the second push. Why is it instantaneous?
- Verify with
docker manifest inspectthat both tags have the same digest. - Delete all local copies and run the API by pulling it from the registry. Check
/healthand the healthcheck's status. - Get the digest and run the image referencing it by
@sha256:…instead of by tag.
Exercise 3: design the Aurora Libros tagging strategy
The Aurora Libros team puts four situations to you. For each one, state which tags you would publish, which you would deploy to production and why:
a) A stable release of version 1.2.0, with new functionality (/books/search) from commit 9c4e1a7 on the main branch.
b) An urgent fix for a security flaw in 1.2.0, which has to be deployed to production today.
c) Pull request number 87, which adds pagination and has to be tested in the review environment.
d) An incompatible change: /books now returns {data: [...], total: n} instead of an array. It breaks the current web front end.
Also, answer:
e) A colleague proposes docker push auroralibros/aurora-api:production on every deployment. Give three concrete technical arguments to reject it and one alternative.
Solutions
Solution to exercise 1
# 2
for T in 1.1 1 latest stable sha-abc1234; do
docker image tag auroralibros/aurora-api:1.1.0 auroralibros/aurora-api:$T
done
# 3
docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}" | head -2Neither the count nor the size has changed. The count is still 9 because docker system df counts images, not references, and the six tags are a single image. docker image tag writes an entry in a name index; it does not touch a single byte of the layers.
TAG IMAGE ID
1 4e9c7d2a8f31
1.1 4e9c7d2a8f31
1.1.0 4e9c7d2a8f31
latest 4e9c7d2a8f31
sha-abc1234 4e9c7d2a8f31
stable 4e9c7d2a8f31Only Untagged, with no Deleted: four references still point at the image, so the data is still there. If you deleted all six, the last one would show Untagged and a list of Deleted: sha256:…, one per exclusive layer. Deleted appears only when the last reference falls, just as you saw in lesson 02-05.
# 6
cd ~/aurora-libros/api
echo "// a change for the exercise" >> server.js
docker build -q -t auroralibros/aurora-api:1.1.0 .
docker image ls auroralibros/aurora-api --format "table {{.Tag}}\t{{.ID}}"Only 1.1.0 has moved to the new image. The other three still point at the old one, which is no longer dangling because it keeps its names. This demonstrates that tagging is an explicit act: Docker propagates nothing. If you want 1.1, 1 and latest to follow the new version, you have to retag them by hand (or generate all the tags in the same docker build with several -ts, as in the script in section 10).
And watch out for what you have just done without meaning to: you have overwritten the content of the 1.1.0 tag. Locally it is harmless; once published, it would be exactly the antipattern from section 9.
Solution to exercise 2
# 1. Pre-flight audit
docker image inspect auroralibros/aurora-api:1.1.0 --format '{{range .Config.Env}}{{println .}}{{end}}'
docker image history auroralibros/aurora-api:1.1.0 --no-trunc --format "{{.CreatedBy}}" \
| grep -iE "password|secret|token|api[_-]?key" || echo "OK: no secrets"
docker run --rm --entrypoint ls auroralibros/aurora-api:1.1.0 -la /app9c1e4a7f2b9d: Pushed
7d3c9f2e8a51: Pushed
2f8e1a9c4b73: Pushed
b8f4e2a91c37: Mounted from library/node
4a1b8c2f9e3d: Mounted from library/node
8f3e1d7c5b9a: Mounted from library/node
e5c2a8f14b76: Mounted from library/node
1.1.0: digest: sha256:c4f81b2e9a7d... size: 1785Three Pushed and four Mounted from. The three uploaded are the layers your Dockerfile generates: COPY package*.json, RUN npm ci and COPY . .. The four mounted belong to node:22-alpine, which was already on Docker Hub: the registry references them instead of receiving them. Out of a 167 MB image, about 25 are transferred. It is the same shared-layer saving from lesson 01-05, now on the network.
Instantaneous because nothing is transferred: all the layers are already in the registry and the manifest is identical. All that happens is that a new name is created pointing at the same digest. Publishing ten tags of the same image costs practically the same as publishing one.
# 4
docker manifest inspect auroralibros/aurora-api:1.1.0 | sha256sum
docker manifest inspect auroralibros/aurora-api:latest | sha256sumb8e2f4a91c37d5e8a2f6c9b1d4e7a3f8c5b2e9d6a1f4c7b0e3a6d9f2c5b8e1a4 -
b8e2f4a91c37d5e8a2f6c9b1d4e7a3f8c5b2e9d6a1f4c7b0e3a6d9f2c5b8e1a4 -Identical.
# 5
docker image rm -f $(docker image ls -q auroralibros/aurora-api)
docker image ls auroralibros/aurora-api
docker run -d --name from-registry -p 3000:3000 auroralibros/aurora-api:1.1.0
sleep 45
curl -s http://localhost:3000/health
docker ps --filter name=from-registry --format "{{.Names}}: {{.Status}}"(empty listing)
Unable to find image 'auroralibros/aurora-api:1.1.0' locally
1.1.0: Pulling from auroralibros/aurora-api
...
{"service":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",...}
from-registry: Up 45 seconds (unhealthy)It works from scratch, and the healthcheck from lesson 02-04 does its job: unhealthy because there is no PostgreSQL and no Redis, exactly as expected until module 3.
# 6. By digest
DIGEST=$(docker image ls --digests auroralibros/aurora-api --format "{{.Digest}}" | head -1)
echo "$DIGEST"
docker rm -f from-registry
docker run -d --name by-digest -p 3000:3000 "auroralibros/aurora-api@$DIGEST"
docker ps --filter name=by-digest --format "{{.Image}}"sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3
auroralibros/aurora-api@sha256:c4f81b2e9a7d3061f5b8c2e4a9d7f3b1e6c8a2d4f9b7e3c1a5d8f2b6e4c9a7d3Look at the IMAGE column of docker ps: it shows the digest, not a tag. That container is cryptographically tied to a specific piece of content, and no amount of tag rewriting in the registry can change what it runs.
Solution to exercise 3
(a) Stable release 1.2.0 from 9c4e1a7:
docker build -t auroralibros/aurora-api:1.2.0 \
-t auroralibros/aurora-api:1.2 \
-t auroralibros/aurora-api:1 \
-t auroralibros/aurora-api:latest \
-t auroralibros/aurora-api:sha-9c4e1a7 \
--build-arg VERSION=1.2.0 --build-arg REVISION=9c4e1a7 \
--pull ./apiIt is compatible functionality (it adds an endpoint without changing the existing ones), so the MINOR number goes up. In production you deploy 1.2.0, the immutable tag. The moving 1.2, 1 and latest are published for the convenience of whoever is developing, and sha-9c4e1a7 gives exact traceability to the commit.
(b) Urgent security fix:
docker build -t auroralibros/aurora-api:1.2.1 \
-t auroralibros/aurora-api:1.2 \
-t auroralibros/aurora-api:1 \
-t auroralibros/aurora-api:latest \
-t auroralibros/aurora-api:sha-4b8f2c1 \
--build-arg VERSION=1.2.1 --build-arg REVISION=4b8f2c1 \
--pull --no-cache ./apiThe PATCH goes up: it is a compatible fix. You deploy 1.2.1. Three important decisions:
1.2.0is not touched. It still exists and still points at the old image. That allows an immediate rollback if1.2.1goes wrong, and it is the whole difference between a controlled incident and one that gets worse.--no-cache, because this is a security patch and you do not want to reuse layers that might contain the vulnerable version of a dependency.--pull, so that thenode:22-alpinebase brings in its patches too.
If the vulnerability were in 1.2.0 itself, the correct response is not to delete that tag (it would break everyone using it, including those who have not been able to update yet), but to document it as obsolete in the repository's description and communicate the update.
(c) Pull request 87:
docker build -t auroralibros/aurora-api:pr-87 \
-t auroralibros/aurora-api:sha-e1d7a92 \
--build-arg VERSION=1.2.1-pr87 --build-arg REVISION=e1d7a92 ./apiAn ephemeral pr-87 tag, moving (it gets overwritten with each push to the PR's branch), and an immutable sha-* one so you can reproduce exactly what was tested. latest, 1.2 and any moving tag of the stable series are not published: they would pollute the channel other people consume. When the PR is closed, pr-87 is deleted; it is one of the few legitimate tag deletions, because by definition nobody uses it in production.
(d) Incompatible change in /books:
The MAJOR number goes up, because it breaks existing consumers (the current web front end expects an array). And it is published first as a candidate, 2.0.0-rc.1, not as stable.
Also, latest does not move yet. This is the nuance most teams forget: if latest jumped to 2.0.0, anyone using latest in a test environment would see their front end break without having changed anything. The correct sequence is: publish the RC, update aurora-web to consume the new format, test them together, and only then publish 2.0.0 and move latest and 2. The 1 tag keeps pointing at the 1.x series for anyone who needs time to migrate.
(e) Three arguments against :production:
- It is neither reproducible nor auditable. The question "what code is in production?" stops having an answer: the tag has moved fifty times and there is no record of what it points to today, nor of what it pointed to during last Tuesday's incident.
- It makes rollbacks impossible. When
:productionmoves to the new version, the previous one loses its only reference. Going back requires knowing the old IMAGE ID or digest, and with no written record of that, there is no going back. - It causes heterogeneous deployments. With
imagePullPolicy: Alwaysor after a restart, some replicas pull the new version and others keep the old one: the same tag running two different codebases at once, with intermittent errors that are impossible to reproduce.
A fourth argument, if you need one: it couples the image to the environment, breaking the "build once, deploy everywhere" principle. The same artifact must work for testing and for production; what changes is the injected configuration, not the image.
The alternative: tag by version (1.2.1) and keep in Git, inside the deployment repository, which version corresponds to each environment:
# deploy/production.yaml
image: auroralibros/aurora-api:1.2.1
# deploy/staging.yaml
image: auroralibros/aurora-api:1.3.0-rc.2That way, "what is in production" is answered with a git log on a file, a rollback is reverting a commit, and the complete deployment history is audited. It is exactly the approach that will be developed in lesson 06-07, on deployment strategies and rollback.
Conclusion
You have closed the cycle. docker image tag copies nothing: it creates references, and four tags of the same image take up exactly what one does, as docker system df confirmed without moving a byte. The full name registry/user/repository:tag is what decides where a push goes — there is no --registry option — which is why a denied is almost always a name with no namespace rather than a credentials problem. You have told fixed tags apart from moving ones, you have seen why tagging by environment is an antipattern that erases traceability, and you take away the rule that prevents the most grief: production deploys by immutable tag or by digest, never by latest, with @sha256:… as the only real cryptographic guarantee.
And you have published. auroralibros/aurora-api:1.1.0 lives on Docker Hub and on GitHub Container Registry, with the same digest in both because the image is identical bit for bit. You know how to read a push's output — Pushed for your three layers, Mounted from library/node for the four the registry already had, and of 167 MB only about 25 travelled — how to verify it against the remote registry with docker manifest inspect, how to make a repository private and how to hand out permissions by team following the principle of least privilege. And you know why deleting a published tag breaks other people's restarts, scale-ups and rollbacks, and why the only thing that helps with a leaked secret is rotating the credential.
Take stock of what has changed in this module. You started with a code repository, fifteen manual onboarding steps and an API that only started on machines with Node 22, nvm, PostgreSQL and Redis installed by hand. You finish with a published image that runs with a single command on any machine in the world with Docker: no installing Node, no npm install, no knowing the project. Along the way you have understood the build context and trimmed it from 23.41 MB to 47.83 kB with a .dockerignore, you have tamed the layer cache until you went from 46.7 to 1.4 seconds per build, you have written a Dockerfile line by line justifying every decision, you have made it professional with an unprivileged user, OCI metadata and a real healthcheck against /health, and you have learned to maintain your image store without destroying what matters. Steps 1 to 5 of that list of fifteen are gone.
But your image still returns ECONNREFUSED on /books, and that failure has been waiting for you for three lessons. Inside the container, localhost is the container itself: there is no PostgreSQL and no Redis in there, and that is why the healthcheck marks it unhealthy with every reason to. In module 3, Docker Containers, those containers stop being isolated demonstrations and start really working: you will master the docker run options and the complete lifecycle, you will learn to inspect and debug a container from the inside, you will create a network of your own where aurora-api finds aurora-db by calling it by name, you will give PostgreSQL a volume so that the eight-book catalog survives the container's destruction, and you will set memory limits and restart policies on all four services. By the end of it, curl http://localhost:3000/books will finally return El jardín de senderos que se bifurcan, Rayuela and the other six titles, served from a real database in a real container. The Aurora Libros platform will start to come alive.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
