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

  1. The right question: who owns an image?
  2. The OCI standard and its three specifications
  3. What this means in practice
  4. The map of the pieces: low-level and high-level runtimes
  5. CRI: why Kubernetes does not talk to Docker
  6. Podman: the daemonless architecture
  7. Rootless by default
  8. Command compatibility and what breaks
  9. Pods, generate kube and play kube
  10. Compose, the compatible socket and Testcontainers
  11. Docker versus Podman, and migrating Aurora Libros
  12. containerd and nerdctl
  13. Buildah and Skopeo
  14. Reinforced isolation: gVisor and Kata Containers
  15. Decision table

  1. 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.

  1. 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.

  1. 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.

  1. 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" } } }
docker run --rm --runtime=crun alpine echo "running with crun"

  1. 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.

  1. 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.

  1. 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 user

The 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

  1. 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 same

It 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

  1. Pods, generate kube and play kube

Podman 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...    3

Notice 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 cluster

generate 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.

  1. 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 Podman

Option 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

  1. 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 in

Zero 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.

  1. containerd and nerdctl

containerd 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 level

nerdctl 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.

  1. 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.0

Skopeo 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.0

The --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.

  1. 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 kernel

Aurora 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.

  1. 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, plain podman cannot see. It is the number one source of confusion when starting out.
  • Expecting restart: always in Podman. There is no daemon. Generate units with podman generate systemd and do not forget loginctl enable-linger.
  • Publishing port 80 in rootless mode without adjusting anything. Privileged ports are off limits. Adjust ip_unprivileged_port_start or put a proxy in front.
  • Copying images with skopeo copy without --all. You take only one architecture and the other environment fails on deployment.
  • Using ctr for daily work. It is a containerd debugging tool. Use nerdctl.
  • Tip: run docker buildx imagetools inspect --raw against your image and read the mediaType. Seeing vnd.oci on 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 kube is 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.com

Identical: 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.yaml

What 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 10

The 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

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved