You have spent 44 lessons building images with Docker, and there is something worth saying clearly: those images are not "Docker images". They are artifacts that comply with an open standard, and they run identically on Podman, on containerd, on CRI-O or on a Kubernetes node without changing a byte. This lesson explains why, takes the ecosystem apart piece by piece, and runs ghcr.io/auroralibros/aurora-api:2.0.0 outside Docker to prove it.
Contents
- The right question: who owns an image?
- The OCI standard and its three specifications
- What this means in practice
- The map of the pieces: low-level and high-level runtimes
- CRI: why Kubernetes does not talk to Docker
- Podman: the daemonless architecture
- Rootless by default
- Command compatibility and what breaks
- Pods,
generate kubeandplay kube - Compose, the compatible socket and Testcontainers
- Docker versus Podman, and migrating Aurora Libros
- containerd and
nerdctl - Buildah and Skopeo
- Reinforced isolation: gVisor and Kata Containers
- Decision table
- The right question: who owns an image?
When you ran docker build -t aurora-api:2.0.0 ., Docker did not invent a format of its own. It produced an artifact with a public structure: a JSON manifest, a configuration with the environment variables, the ENTRYPOINT, the user and the history, and a list of compressed layers, all identified by SHA-256 digests.
docker buildx imagetools inspect ghcr.io/auroralibros/aurora-api:2.0.0 --raw | jq '.'
# {
# "mediaType": "application/vnd.oci.image.index.v1+json",
# "manifests": [
# { "platform": { "architecture": "amd64", "os": "linux" }, "digest": "sha256:6b1..." },
# { "platform": { "architecture": "arm64", "os": "linux" }, "digest": "sha256:c47..." }
# ]
# }Look at the mediaType: it says vnd.oci.image.index.v1+json, not "docker". That prefix is the literal proof that your image is an OCI artifact, and that the multi-architecture work you set up in 05-05 is a feature of the standard, not of Docker.
- The OCI standard and its three specifications
The Open Container Initiative was born in 2015 inside the Linux Foundation, driven by Docker itself, which donated its image format and its runc runtime. The motivation was to avoid a war of incompatible formats (there was a serious competitor, rkt, with a format of its own). The result is that today the container is a standard and not a product.
| Specification | What it standardizes | Where you have seen it in the course |
|---|---|---|
| Image Spec | The image format: manifest, config, layers, digests, multi-architecture indexes | Every docker build and every docker inspect |
| Runtime Spec | How a bundle is executed: the config.json with namespaces, cgroups, capabilities and mounts |
The runc config.json you opened in 05-07 |
| Distribution Spec | The HTTP protocol of registries: push, pull, tags, digests, authentication |
Every docker push to ghcr.io (02-06) |
graph TB
subgraph oci["OCI standard"]
IS["Image Spec<br/>the image format"]
RS["Runtime Spec<br/>config.json and execution"]
DS["Distribution Spec<br/>the registry protocol"]
end
subgraph builders["They build (Image Spec)"]
BK["BuildKit"]; BAH["Buildah"]; KANIKO["Kaniko"]; PACK["Buildpacks"]
end
subgraph registries["They store (Distribution Spec)"]
GHCR["ghcr.io"]; HARBOR["Harbor"]; ZOT["Zot"]; REG["registry:2"]
end
subgraph runners["They run (Runtime Spec)"]
DOCKER["Docker Engine"]; PODMAN["Podman"]; CTD["containerd"]; CRIO["CRI-O"]
end
builders --> IS --> registries
registries --> DS
IS --> runners
runners --> RS
Any builder produces images that any registry stores and any runtime runs. That complete grid is what the standard buys you.
- What this means in practice
| Claim | True? |
|---|---|
| "I need Docker to run a Docker image" | False: any OCI runtime runs it |
| "Kubernetes runs Docker images" | Imprecise: it runs OCI images, and it has not used Docker since 2022 |
| "If I change runtime, I have to rebuild" | False: the same digest works |
| "Docker Hub is only for Docker" | False: it implements the Distribution Spec |
| "Cosign and SBOMs are a Docker thing" | False: they are OCI artifacts in the registry |
The direct consequence for Aurora Libros: ghcr.io/auroralibros/aurora-api:2.0.0 — 104 MB, two architectures, signed with Cosign, with an SBOM and a provenance attestation — runs exactly the same on Docker, on Podman, on containerd or on any Kubernetes node, and its signature verifies identically on all of them. You have not learned a product: you have learned a standard and one of its implementations.
- The map of the pieces: low-level and high-level runtimes
graph TB
U["You: docker / podman / nerdctl / kubectl"]
subgraph high["HIGH-level runtimes (managers)"]
DE["Docker Engine"]; PM["Podman"]; CD["containerd"]; CO["CRI-O"]
end
subgraph low["LOW-level runtimes (OCI Runtime Spec)"]
RC["runc (C/Go)"]; CR["crun (C)"]; YK["youki (Rust)"]
GV["gVisor"]; KT["Kata Containers"]
end
K["Linux kernel: namespaces, cgroups, seccomp, capabilities"]
U --> high
DE --> CD
high --> low --> K
| Layer | What it does | Pieces |
|---|---|---|
| Low level | Takes a bundle + config.json and creates the isolated process. It lives for milliseconds |
runc, crun, youki, gVisor, Kata |
| High level | Pulls images, manages layers, networks, volumes and lifecycle; calls the low-level one | containerd, CRI-O, Podman, Docker Engine |
| Interface | What you type, or the API Kubernetes consumes | docker, podman, nerdctl, ctr, CRI |
| Low-level runtime | Language | Characteristic |
|---|---|---|
runc |
Go | The reference implementation; the one you use |
crun |
C | Faster and lighter; less memory. The default in Podman on Fedora |
youki |
Rust | Memory safety by design; young but functional |
gVisor (runsc) |
Go | A kernel in user space: strong isolation, high cost |
| Kata | Go | One microVM per container: VM-grade isolation |
Changing the low-level one is a single line of configuration, and the images never notice:
// /etc/docker/daemon.json
{ "default-runtime": "runc",
"runtimes": { "crun": { "path": "/usr/bin/crun" } } }
- CRI: why Kubernetes does not talk to Docker
Kubernetes needs to talk to some runtime on each node. Instead of coupling itself to one, it defined the Container Runtime Interface, a gRPC API with two services: RuntimeService (the lifecycle of Pods and containers) and ImageService (images).
Docker Engine never implemented CRI, so an adapter called dockershim existed inside the kubelet itself. It was Kubernetes code maintaining compatibility with one specific product, and it was removed in version 1.24 (2022).
graph LR
subgraph before["Before 1.24"]
KL1["kubelet"] --> DS["dockershim"] --> DE["Docker Engine"] --> CD1["containerd"] --> RC1["runc"]
end
subgraph now["From 1.24 onwards"]
KL2["kubelet"] -->|CRI| CD2["containerd or CRI-O"] --> RC2["runc"]
end
That was communicated terribly and generated headlines along the lines of "Kubernetes drops support for Docker". What actually happened was that an intermediary was removed: containerd, which was already down there, started talking directly to the kubelet. Your images were not affected at all, because they are OCI. It is the best possible illustration of this lesson's thesis.
- Podman: the daemonless architecture
Podman (Red Hat) is the most relevant alternative to Docker, and its structural difference is that there is no daemon.
graph TB
subgraph docker["Docker"]
DC["docker client"] -->|"socket, root"| DD["dockerd (root, always alive)"]
DD --> C1["container"]
end
subgraph podman["Podman"]
PC["podman (your user)"] --> CN["conmon"] --> C2["container"]
SD["systemd --user"] -.->|"supervises"| C2
end
| Consequence | Docker | Podman |
|---|---|---|
| A process always running | dockerd as root |
None |
| Owner of the containers | The daemon | Your user |
| If the manager dies | Risk to the containers | They stay alive: conmon supervises them |
| Attack surface | One socket = root on the host (05-03) | There is no privileged socket |
| Starting at boot | restart: managed by the daemon |
Generated systemd units |
| System auditing | Everything shows up as a daemon action | It shows up as an action by your user |
The fourth row is the underlying reason. The "the docker group is equivalent to root" you learned in 05-03 simply does not exist in Podman: there is no privileged socket to belong to. And the last one matters in regulated environments: audit logs attribute actions to people, not to a daemon.
- Rootless by default
Docker has supported rootless mode since 20.10, but you have to enable it. In Podman it is the norm from the start, resting on user namespaces (05-07) and on the ranges in /etc/subuid and /etc/subgid.
podman run -d --name aurora-cache redis:7-alpine
podman top aurora-cache huser user
# HUSER USER
# 100998 redis ← UID 999 inside; UID 100998 on the host
id -u
# 1000 ← and the process belongs to your userThe container believes it is the redis user (UID 999) and the kernel sees it as 100998, a UID with no privileges whatsoever. A container escape does not give root: it gives you a user that can do nothing.
| Rootless mode limitation | Solution |
|---|---|
| You cannot publish ports below 1024 | sysctl net.ipv4.ip_unprivileged_port_start=80 or a proxy in front |
Network performance goes through slirp4netns/pasta |
pasta (the default in recent versions) improves it a lot |
| Some file systems do not behave the same | Use fuse-overlayfs |
Mounting devices or changing host sysctl values |
Requires privileges: rethink the design |
- Command compatibility and what breaks
Podman deliberately replicates the Docker CLI:
alias docker=podman
docker run -d -p 8080:8080 ghcr.io/auroralibros/aurora-api:2.0.0
docker ps ; docker logs ; docker exec ; docker build ; docker inspect # all the sameIt works for the vast majority of daily work. What is not identical:
| Difference | Detail |
|---|---|
Implicit docker.io |
Podman asks which registry or uses registries.conf; always write the full name |
| Image store | Separate per user in ~/.local/share/containers; what you build as root your user cannot see |
--privileged |
Fewer real privileges in rootless: you are still limited by your user |
| Networking | netavark instead of Docker's bridge; internal DNS works the same but is configured differently |
restart: always |
There is no daemon to restart anything: a systemd unit is generated |
| Swarm | Does not exist; the path is Kubernetes |
| Docker Desktop | The equivalent is Podman Desktop |
- Pods,
generate kube and play kube
generate kube and play kubePodman borrows its grouping unit from Kubernetes: the pod, a set of containers that share a network namespace (and therefore localhost and the ports).
podman pod create --name aurora --publish 8080:8080
podman run -d --pod aurora --name aurora-cache redis:7-alpine
podman run -d --pod aurora --name aurora-api \
-e REDIS_URL=redis://localhost:6379 \
ghcr.io/auroralibros/aurora-api:2.0.0
podman pod ps
# POD ID NAME STATUS INFRA ID # OF CONTAINERS
# 4f2a... aurora Running 9c1b... 3Notice REDIS_URL=redis://localhost:6379: inside a pod, the containers share the network interface, so they reach each other over localhost. It is exactly the model of a Kubernetes Pod, rehearsed on your laptop.
And from there comes Podman's most useful bridge:
podman generate kube aurora > aurora-pod.yaml # from containers to a K8s manifest
podman play kube aurora-pod.yaml # and from a K8s manifest to containers
kubectl apply -f aurora-pod.yaml # the same file, on the clustergenerate kube produces a Pod (or a Deployment with --type deployment) ready for review. It does not replace the work from module 6 — it generates no Ingress, HPA, PDB or tuned probes — but as a draft it is far better than kompose, because it starts from something that already works.
- Compose, the compatible socket and Testcontainers
Two ways of running your compose.yaml:
# Option A: podman-compose (a Python implementation, partial coverage)
podman-compose -f compose.yaml up -d
# Option B (recommended): the Docker API-compatible socket
systemctl --user enable --now podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
docker compose up -d # the real docker compose, against PodmanOption B is superior because it uses the official Compose v2, with its full coverage of keys, talking to Podman through a socket that emulates the Docker API. And that same socket enables everything else:
# Testcontainers (07-04) working against Podman
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
export TESTCONTAINERS_RYUK_DISABLED=true # Ryuk needs tweaking in rootless mode
node --test test/ # a real PostgreSQL 16 and Redis 7| Tool | Does it work against Podman? | Note |
|---|---|---|
docker compose v2 |
Yes, via the socket | The best option |
podman-compose |
Partially | Advanced keys not covered |
| Testcontainers | Yes | Disable Ryuk or configure it |
Trivy, dive, hadolint |
Yes | They read images through the socket or from the registry |
| Portainer | Yes, with caveats | Point it at the Podman socket |
- Docker versus Podman, and migrating Aurora Libros
| Aspect | Docker | Podman |
|---|---|---|
| Architecture | Client + daemon | Daemonless, fork/exec |
| Root by default | Yes (rootless optional) | Rootless by default |
| A group with root power | Yes: the docker group |
It does not exist |
| Compose | Native, v2, complete | Via the socket (recommended) or podman-compose |
| Its own orchestration | Swarm | None: it points you at Kubernetes |
| Pods | No | Yes, Kubernetes-style |
| Bridge to K8s | kompose (limited) |
generate kube / play kube |
| Building | BuildKit (excellent) | Buildah (built in, daemonless) |
| Automatic startup | restart: |
systemd units |
| Windows and macOS | Docker Desktop | podman machine / Podman Desktop |
| Ecosystem and documentation | Enormous | Good, smaller |
| The default on | Almost everywhere | RHEL, Fedora, CentOS Stream |
Migrating Aurora Libros, step by step and with the proof at the end:
# 1. The same image, with nothing rebuilt
podman pull ghcr.io/auroralibros/aurora-api:2.0.0
podman image inspect ghcr.io/auroralibros/aurora-api:2.0.0 \
--format '{{.Digest}} {{.Architecture}}'
# sha256:c47e... arm64 ← the same digest Cosign verifies
# 2. The signature verifies the same: it is an OCI artifact in the registry
cosign verify ghcr.io/auroralibros/aurora-api:2.0.0 \
--certificate-identity-regexp 'auroralibros' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# 3. The whole stack, with the compose.yaml untouched
systemctl --user enable --now podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
docker compose up -d --wait
# 4. The proof that it really works
curl -s localhost:8080/books | jq -r '.books[] | .title' | head -3
# El jardín de senderos que se bifurcan
# Rayuela
# Cien años de soledad
curl -s localhost:8080/books/4 | jq -r '.source' # db
curl -s localhost:8080/books/4 | jq -r '.source' # cache ← cache-aside intact
# 5. Making them survive a reboot, with systemd instead of restart:
podman generate systemd --new --files --name aurora-api
systemctl --user enable --now container-aurora-api.service
loginctl enable-linger $USER # so they start without logging inZero changes to the image, zero changes to the compose.yaml, the same verifiable signature and cache-aside returning db and then cache. The only real operational difference is in step 5: where Docker had restart: unless-stopped, here there are user systemd units, and the enable-linger is the detail everybody forgets.
- containerd and
nerdctl
nerdctlcontainerd is a graduated CNCF project, and it is the piece you are already using without knowing it: Docker Engine delegates to it. It manages images, snapshots, basic networking and the lifecycle, and it calls runc. It is also the default runtime of most managed Kubernetes offerings.
Its native CLI, ctr, is a debugging tool, not a working one:
sudo ctr images pull ghcr.io/auroralibros/aurora-api:2.0.0
sudo ctr run --rm ghcr.io/auroralibros/aurora-api:2.0.0 test
# No convenient networking, no volumes, no port publishing: it is low levelnerdctl is what makes containerd usable: it replicates the Docker CLI, includes BuildKit and ships Compose.
nerdctl run -d -p 8080:8080 ghcr.io/auroralibros/aurora-api:2.0.0
nerdctl compose -f compose.yaml up -d # yes: Compose on top of containerd
nerdctl build -t aurora-api:2.1.0 . # BuildKit underneath| Tool | Level | What for |
|---|---|---|
ctr |
Very low | Debugging containerd; never for daily work |
crictl |
CRI | Debugging Kubernetes nodes: seeing Pods from the node |
nerdctl |
High | Working with containerd as if it were Docker |
nerdctl also exposes features Docker does not have: encrypted images, lazy pulling with stargz (starting without downloading the whole image) and Wasm containers. It is the usual way to experiment with what is coming.
- Buildah and Skopeo
Buildah builds OCI images with no daemon and with no Dockerfile if you do not want one:
buildah bud -t aurora-api:2.0.0 . # uses your Dockerfile as it is
# or imperatively, useful in scripts:
ctr=$(buildah from node:22-alpine)
buildah copy "$ctr" src /app/src
buildah config --user 10001 --port 8080 --entrypoint '["node","/app/src/index.js"]' "$ctr"
buildah commit "$ctr" aurora-api:2.0.0Skopeo is the one people miss most once they know about it: it inspects and copies images between registries without downloading them to your local disk.
# Inspect a remote image without pulling it
skopeo inspect docker://ghcr.io/auroralibros/aurora-api:2.0.0 \
| jq '{Digest, Architecture, Created, Labels}'
# Copy between registries directly (it never touches your disk)
skopeo copy --all \
docker://ghcr.io/auroralibros/aurora-api:2.0.0 \
docker://registry.internal:5000/aurora/aurora-api:2.0.0
# Check whether the production tag changed, without downloading 104 MB
skopeo inspect --format '{{.Digest}}' docker://ghcr.io/auroralibros/aurora-api:2.0.0The --all copies the complete multi-architecture index, not just your machine's variant: without it you would replicate half an image and the amd64 server would fail. And the last command is pure gold in a pipeline: verifying the production digest costs one HTTP request instead of a full download.
- Reinforced isolation: gVisor and Kata Containers
Containers share the host kernel. If a privilege escalation vulnerability turns up in the kernel, the isolation breaks. These two runtimes attack exactly that.
gVisor (runsc) |
Kata Containers | |
|---|---|---|
| How it isolates | A kernel reimplemented in user space that intercepts syscalls | A microVM with its own kernel per container |
| Isolation | High | Very high (a hypervisor boundary) |
| Startup overhead | ~50-150 ms | ~100-500 ms |
| I/O overhead | Noticeable | Moderate |
| Compatibility | Some syscalls are unsupported | Total: it is real Linux |
| Requires virtualization | No | Yes (nested in the cloud) |
| Typical use | Running untrusted code | Hard multi-tenancy, compliance |
docker run --rm --runtime=runsc alpine dmesg | head -1
# Starting gVisor... ← that is not the host kernelAurora Libros does not need them: your code is yours and it already runs unprivileged, with read_only, cap_drop: [ALL] and no-new-privileges. They are justified when you run code you do not control — customer functions, CI for public repositories, third-party notebooks — and there the performance cost is paid without argument.
- Decision table
| Situation | The natural choice | Why |
|---|---|---|
| Team development, broad ecosystem | Docker | Documentation, Desktop, native Compose, everybody knows it |
| RHEL, Fedora or CentOS Stream | Podman | It is the system standard, integrated with systemd |
| A "nothing as root" policy | Podman | Rootless by default, no group with root power |
| A Kubernetes node | containerd or CRI-O | They speak CRI directly |
| Working with containerd by hand | nerdctl |
The Docker CLI on top of containerd, with BuildKit |
| Building in CI with no daemon | Buildah, Kaniko or BuildKit | No privileges and no exposed socket |
| Copying or auditing images between registries | Skopeo | Without downloading anything |
| Running untrusted code | gVisor or Kata | Reinforced isolation |
| Compose, Swarm or Docker Desktop | Docker | Podman has no full equivalent |
The honest conclusion: in 2026 Docker is still the natural choice for development thanks to its ecosystem and convenience, Podman wins in environments where security and system policy rule, and containerd is what runs the world in production without almost anybody typing it. And all three run the very same image of yours.
Common Mistakes and Tips
- Believing you have to rebuild to change runtime. You do not. The digest is the same and the Cosign signature verifies identically.
- Repeating that "Kubernetes dropped support for Docker". What was removed was dockershim, an adapter. OCI images were never in question.
- Mixing root and user containers in Podman. The stores are separate: what you build with
sudo podman, plainpodmancannot see. It is the number one source of confusion when starting out. - Expecting
restart: alwaysin Podman. There is no daemon. Generate units withpodman generate systemdand do not forgetloginctl enable-linger. - Publishing port 80 in rootless mode without adjusting anything. Privileged ports are off limits. Adjust
ip_unprivileged_port_startor put a proxy in front. - Copying images with
skopeo copywithout--all. You take only one architecture and the other environment fails on deployment. - Using
ctrfor daily work. It is a containerd debugging tool. Usenerdctl. - Tip: run
docker buildx imagetools inspect --rawagainst your image and read themediaType. Seeingvnd.ocion screen fixes the idea better than any explanation. - Tip: if Podman interests you, start with the compatible socket and
docker compose. You migrate the execution without migrating the tooling. - Tip:
podman generate kubeis the best manifest draft there is, because it starts from something that already works.
Exercises
Exercise 1 — Prove the portability. Run ghcr.io/auroralibros/aurora-api:2.0.0 with Docker and with Podman (or nerdctl) on the same machine. Compare the image digest in both, check that the /books endpoint returns the nine titles in both cases, and verify the Cosign signature against the registry. Write down what is identical and what changes between the two runs.
Exercise 2 — A Kubernetes-style pod with Podman. Create an aurora pod containing aurora-cache (redis:7-alpine) and aurora-api, communicating over localhost, with port 8080 published. Check that cache-aside works (source: db the first time, cache the second). Then generate the Kubernetes manifest with podman generate kube, read it, and note three things it is missing to be production-ready according to what you learned in module 6.
Exercise 3 — Skopeo in the pipeline. Write a script that, without downloading a single image, checks whether the digest of ghcr.io/auroralibros/aurora-api:2.0.0 matches the one deployed in production (read it from a digest-production.txt file), and that in case of a mismatch replicates the complete image, with both its architectures, to the internal registry registry.internal:5000. The script must return 0 if everything matches and 10 if it had to replicate.
Solutions
Solution 1.
IMG=ghcr.io/auroralibros/aurora-api:2.0.0
docker pull "$IMG" && podman pull "$IMG"
docker image inspect "$IMG" --format 'docker: {{index .RepoDigests 0}}'
podman image inspect "$IMG" --format 'podman: {{index .RepoDigests 0}}'
# docker: ghcr.io/auroralibros/aurora-api@sha256:c47e...
# podman: ghcr.io/auroralibros/aurora-api@sha256:c47e... ← identical
docker run -d --name api-d -p 8080:8080 "$IMG"
podman run -d --name api-p -p 8081:8080 "$IMG"
curl -s localhost:8080/books | jq '.books | length' # 9
curl -s localhost:8081/books | jq '.books | length' # 9
cosign verify "$IMG" \
--certificate-identity-regexp 'auroralibros' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comIdentical: the digest, the content, the API's behavior and the signature verification — because Cosign validates an artifact that lives in the registry, not in the runtime. What changes is everything around the execution: in Docker the container is a child of dockerd (root) and appears in ps aux as such; in rootless Podman it is a child of conmon under your UID, the internal process maps to a high host UID, and podman ps only shows your containers. Networking also changes (netavark versus Docker's bridge), and so does the handling of automatic startup. None of those differences affects the artifact: what runs is exactly the same thing.
Solution 2.
podman pod create --name aurora --publish 8080:8080
podman run -d --pod aurora --name aurora-cache redis:7-alpine
podman run -d --pod aurora --name aurora-api \
-e REDIS_URL=redis://localhost:6379 \
-e DB_HOST=host.containers.internal -e DB_NAME=aurora_books \
ghcr.io/auroralibros/aurora-api:2.0.0
curl -s localhost:8080/books/6 | jq -r '.title, .source'
# La casa de los espíritus
# db
curl -s localhost:8080/books/6 | jq -r '.source'
# cache
podman generate kube aurora > aurora-pod.yamlWhat the generated manifest is missing to be production-ready: (1) the three probes — at most it generates whatever it inferred from the container, not the startupProbe, livenessProbe and readinessProbe on /health/live and /health/ready that you separated out in 06-01; (2) resources with requests and limits, without which the scheduler cannot place the Pod sensibly and the HPA cannot calculate a usage percentage; (3) it is a bare Pod, not a Deployment, so there are no replicas, no rolling updates, no rollback and no recovery if the node falls over. I would add two more: there is no ConfigMap/Secret (the configuration goes in as literal variables, credentials included) and there is no Service or Ingress. It works as an excellent draft because it starts from something that runs, and as a reminder that everything that makes a deployment production-ready is deliberate work.
Solution 3.
#!/usr/bin/env bash
# sync-registry.sh — replicates only if the digest changed. Nothing is downloaded.
set -euo pipefail
SOURCE="docker://ghcr.io/auroralibros/aurora-api:2.0.0"
DEST="docker://registry.internal:5000/aurora/aurora-api:2.0.0"
FILE="digest-production.txt"
REMOTE=$(skopeo inspect --format '{{.Digest}}' "$SOURCE")
CURRENT=$(cat "$FILE" 2>/dev/null || echo "none")
if [ "$REMOTE" = "$CURRENT" ]; then
echo "No changes: $REMOTE"
exit 0
fi
echo "New digest detected"
echo " production: $CURRENT"
echo " registry: $REMOTE"
skopeo copy --all "$SOURCE" "$DEST" # --all = both architectures
skopeo inspect --raw "$DEST" | jq -r '.manifests[].platform.architecture'
# amd64
# arm64
echo "$REMOTE" > "$FILE"
exit 10The key points. skopeo inspect --format '{{.Digest}}' makes a single HTTP request to the manifest: checking whether something changed costs milliseconds instead of downloading 104 MB, and that lets you run it every five minutes at no cost. skopeo copy transfers registry to registry without materializing layers on the local disk, something a docker pull followed by a docker push cannot do. The --all is essential: without it you would copy only the manifest matching the runner's architecture, and the day an arm64 node pulled from the internal copy, the pull would fail with a platform error that is quite hard to diagnose; the subsequent check with --raw confirms that both architectures arrived. And exit code 10 distinguishes "there was nothing to do" from "I replicated", which is what lets you chain a notification step in the pipeline only when there really was a change.
Conclusion
You now know why the images you have built throughout the course are not "Docker images". You have verified it by reading the mediaType of your own multi-architecture index: application/vnd.oci.image.index.v1+json. The Open Container Initiative standardizes three things — the Image Spec that shapes your images, the Runtime Spec that defines the config.json you opened in 05-07, and the Distribution Spec that governs every push to ghcr.io — and from there comes the complete grid: any builder, any registry, any runtime.
You have the map of the pieces sorted by layer: low-level runtimes that live for milliseconds (runc, crun, youki) versus high-level managers (containerd, CRI-O, Podman, Docker Engine), with CRI in the middle explaining why the kubelet talks to containerd and why removing dockershim in 1.24 meant getting rid of an intermediary, not dropping support for your images.
You know Podman in depth: daemonless, with containers as children of your user rather than of a root process, which makes the "the docker group is root" from 05-03 simply not exist; rootless by default on top of user namespaces, with its four real limitations and their solutions; the pods that rehearse the Kubernetes model on your laptop with localhost between containers; generate kube and play kube as the best manifest draft there is, because it starts from something that works; and the compatible socket that lets you use the official docker compose and Testcontainers without changing tools. And you have genuinely migrated Aurora Libros: same image, same digest, same verified signature, same compose.yaml, with cache-aside returning db and then cache, and user systemd taking the place of restart: unless-stopped.
Completing the picture: containerd — the piece you were already using without knowing it — with ctr for debugging and nerdctl for working, including nerdctl compose; Buildah for building without a daemon; Skopeo for inspecting and copying between registries without downloading anything, with the --all that stops you replicating half an image; and gVisor and Kata for when you have to run code you do not control and the performance cost is happily paid. The decision table tells you when Docker is still the natural choice and when something else pays off.
In the final lesson of the course we lift our eyes from all of it: where the container ecosystem is heading, which trends have substance and which are a bet, what is not going to change — and is therefore where your learning is best invested — and the full closing of the road Aurora Libros has travelled since those fifteen manual onboarding steps.
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
