The aurora-api:2.0.0 image is correct, but you are the one building it, on your laptop, with your cache and your git status. This lesson takes the human out of the loop: every git push fires a chain that runs the tests, builds for two architectures, scans, signs, publishes and deploys, without anybody having to remember a thing.
Contents
- CI, delivery and deployment: three concepts, two acronyms
- Why Docker fits: the artifact is the image
- Anatomy of the pipeline
- Testing inside containers with Compose
- The Dockerfile's
testsstage and--target - Aurora Libros' pipeline: triggers and permissions
- Automatic tagging with
metadata-action - Multi-architecture builds with the
ghacache - Trivy scanning that breaks the build
- Keyless signing, SBOM and provenance
- The equivalent pipeline in GitLab CI
- Docker-in-Docker versus the mounted socket
- Credential management: OIDC and ephemeral tokens
- Automatic versioning from Git tags
- Automated deployment
Warning. A CI runner with access to your registry and your servers is a critical piece of infrastructure: whoever controls the pipeline can publish and deploy anything. Runner permissions, token scopes and SSH access to the environments must be defined and reviewed with the infrastructure and security officer in your organization.
- CI, delivery and deployment: three concepts, two acronyms
| Concept | What it automates | Where it ends | Human intervention |
|---|---|---|---|
| Continuous integration (CI) | Compiling, analyzing and testing every change | A validated artifact | None |
| Continuous delivery | All of the above + leaving the artifact ready to deploy | The registry, with the image published | Somebody approves the deployment |
| Continuous deployment | All of the above + deploying automatically | Production | None |
The last two share the abbreviation CD and get confused constantly. The practical difference is a button: with delivery, the image is ready and waiting for an "approve"; with deployment, every merge to main that clears all the gates reaches production on its own. Aurora Libros will do delivery to production and deployment to staging: it is the most common combination and the most sensible one for as long as test coverage is not good enough to trust blindly.
- Why Docker fits: the artifact is the image
Before containers, the CI artifact was a .jar, a .zip or a tarball, and the environment it ran in was prepared separately: that is where "it works on my machine" came from, because the artifact traveled and the environment did not. With Docker, the artifact includes its environment, and that unlocks three properties that make a pipeline trustworthy:
- Immutability. The digest identifies an exact content:
sha256:a1b2...is the same thing in CI, instagingand in production, forever. - Promotion without rebuilding. You promote by retagging. Rebuilding for production means deploying an artifact nobody has tested.
- Environment parity. The container that passed the tests is, byte for byte, the one serving customers.
The rule that sums up the module: build once, deploy many times. One commit produces one image, that image has a digest, and that digest is what gets promoted to staging and to production. If your pipeline has one docker build per environment, it has a design flaw.
- Anatomy of the pipeline
flowchart TD
A[checkout] --> B[lint]
B --> C[tests in a container]
C --> D[multi-architecture build]
D --> E[CVE scan]
E --> F[signature + SBOM]
F --> G[publish to the registry]
G --> H{branch?}
H -->|main| I[deploy staging]
H -->|tag v*| J[deploy production]
| Phase | What can fail | How long it takes | Does it break the build? |
|---|---|---|---|
| Checkout + lint | Submodules, style, a forgotten console.log |
~25 s | Yes |
| Tests | Regression, flakiness from dependencies | 1-3 min | Yes |
| Build | Compilation failure, cold cache | 40 s - 4 min | Yes |
| Scan | A critical CVE in a dependency | ~30 s | Yes (criticals) |
| Signing / SBOM | Misconfigured OIDC permissions | ~15 s | Yes |
| Publishing | Credentials, registry quota | ~30 s | Yes |
| Deployment | Network, probes that never pass | 1-5 min | Yes, with rollback |
The order is not arbitrary: whatever is cheap and whatever fails most, first. A 20-second lint that catches the error saves you spending four minutes on a multi-architecture build. It is the same principle as the layer cache from 02-02, applied to the pipeline.
- Testing inside containers with Compose
Testing against a real database rather than a mock is what separates a useful test from one that green-lights broken code. With Compose it is trivial and, more importantly, identical on your laptop and on the runner.
# compose.tests.yaml — an ephemeral stack, no volumes: it dies without leaving a trace
services:
tests:
build: { context: ./api, target: tests } # the Dockerfile stage from 06-01
environment:
DB_HOST: db-test
DB_USER: aurora
DB_PASSWORD: dummy-test-secret
DB_NAME: aurora_books_test
REDIS_HOST: cache-test
depends_on:
db-test: { condition: service_healthy }
cache-test: { condition: service_healthy }
db-test:
image: postgres:16-alpine
environment: { POSTGRES_USER: aurora, POSTGRES_PASSWORD: dummy-test-secret, POSTGRES_DB: aurora_books_test }
volumes: ["./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro"]
tmpfs: ["/var/lib/postgresql/data"] # data in RAM: faster and genuinely ephemeral
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_books_test"]
interval: 2s
retries: 15
cache-test:
image: redis:7-alpine
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 2s, retries: 15 }docker compose -f compose.tests.yaml up \
--build --abort-on-container-exit --exit-code-from tests
docker compose -f compose.tests.yaml down -vThose two flags are the heart of the matter, and they are worth understanding properly:
--abort-on-container-exitstops the whole stack as soon as one container finishes. Without it, the tests end and PostgreSQL keeps running: the command never returns and the job hangs until the timeout.--exit-code-from testsmakesdocker compose's exit code the test container's, not that of the Compose operation. Without it, the command returns0even when the tests fail, and the pipeline goes green over broken code. This is, by a wide margin, the most frequent mistake in this lesson.
The tmpfs over PostgreSQL's data directory deserves a note: in tests you do not need durability, so putting the data in RAM removes disk writes and usually cuts the suite by somewhere between 30 % and 50 %. Never, ever in production.
- The Dockerfile's
tests stage and --target
tests stage and --targetThe Dockerfile from 06-01 already had the stage ready. Running it on its own is a --target:
| Approach | Advantage | Drawback |
|---|---|---|
The tests stage with --target |
The same Dockerfile, the same cached layers |
It needs the services separately |
compose.tests.yaml |
A full stack with a real DB and cache | One more file to maintain |
| No container, straight on the runner | Blazing fast | The runner stops being reproducible |
Aurora Libros uses the first two together: compose.tests.yaml builds with target: tests. And there is a far from minor cache benefit: the deps stage that installs node_modules is shared by the tests and the final image, so the production build reuses those layers and reinstalls nothing.
- Aurora Libros' pipeline: triggers and permissions
# .github/workflows/ci.yaml
name: CI/CD Aurora Libros
on:
push:
branches: [main]
tags: ["v*.*.*"] # v2.0.0 triggers the release publication
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE: ${{ github.repository_owner }}/aurora-api
# A new push on the same branch cancels the previous pipeline: it saves minutes and quota
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Lint and tests against a real PostgreSQL and Redis
run: |
docker compose -f compose.tests.yaml up \
--build --abort-on-container-exit --exit-code-from tests
- name: Tear down the stack
if: always() # even if the tests failed
run: docker compose -f compose.tests.yaml down -vAbout the triggers: pull requests run the tests and build without publishing, because a branch belonging to nobody should not be able to push an image to the registry. Pushes to main publish with the edge tag and deploy to staging. Tags matching v*.*.* produce the publishable SemVer version.
- Automatic tagging with
metadata-action
metadata-action publish:
needs: tests # nothing gets built if the tests failed
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # publish to ghcr.io
id-token: write # OIDC for Cosign's keyless signing
security-events: write # upload the Trivy report to the Security tab
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # an ephemeral token, not a password
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}} # v2.0.0 -> 2.0.0
type=semver,pattern={{major}}.{{minor}} # v2.0.0 -> 2.0
type=semver,pattern={{major}} # v2.0.0 -> 2
type=ref,event=branch # main -> main
type=sha,prefix=sha-,format=short # always -> sha-a1b2c3d
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=aurora-api
org.opencontainers.image.vendor=Aurora Libros S.L.This action replaces the sed and git describe script everybody ends up writing. From the v2.0.0 tag it works out the four moving tags on its own and additionally generates the OCI labels from 06-01 with the date, the commit and the repository URL, without you having to pass them as --build-arg.
type=sha is the most important one on the list even though it looks like the most boring: every build produces a unique, unrepeatable tag, so you can always refer to one specific build even after latest and 2.0 have moved ten times.
- Multi-architecture builds with the
gha cache
gha cache - uses: docker/setup-qemu-action@v3 # emulation for arm64 (05-05)
- uses: docker/setup-buildx-action@v3 # a builder with the docker-container driver
- id: build
uses: docker/build-push-action@v5
with:
context: ./api
target: runtime
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} # PRs build, they don't publish
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=aurora-api
cache-to: type=gha,scope=aurora-api,mode=max
provenance: mode=max # SLSA provenance attestation
sbom: true # SBOM attached to the image index
build-args: |
REVISION=${{ github.sha }}The two cache parameters are the difference between a usable pipeline and an unbearable one. Without them, every run starts with an empty cache —the runner is a brand-new machine— and npm ci runs in full twice, once per architecture. With type=gha, BuildKit stores the layers in the GitHub Actions cache and pulls them back on the next build.
| Configuration | Cold build | Cached build | With a change only in src/ |
|---|---|---|---|
| No cache | 4 min 10 s | 4 min 10 s | 4 min 10 s |
type=gha,mode=min |
4 min 20 s | 1 min 05 s | 55 s |
type=gha,mode=max |
4 min 35 s | 48 s | 41 s |
mode=max also stores the intermediate layers of the earlier stages —deps and deps-dev included—, so it takes up more cache but hits far more often. The scope keeps two different workflows from stepping on each other's entries. And one operational warning: the GitHub Actions cache has a 10 GB limit per repository and evicts by age, so a mode=max across many branches can evict entries you still need.
- Trivy scanning that breaks the build
- name: Vulnerability scan
uses: aquasecurity/[email protected]
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true # with no patch available, breaking the build fixes nothing
exit-code: "0" # this step only reports...
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: trivy.sarif }
- name: "Quality gate: no fixable criticals"
uses: aquasecurity/[email protected]
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
severity: CRITICAL
ignore-unfixed: true
exit-code: "1" # ...and this one breaks the buildThe scan runs by digest, not by tag: that guarantees you are analyzing exactly the image you have just built and not some other one somebody published under the same name in the meantime.
The two-step structure is deliberate. The first collects everything (criticals and highs) and uploads it to the security tab for visibility; the second, far stricter, is the gate: it only breaks on criticals with a patch available. That ignore-unfixed is not a relaxation, it is pragmatism: blocking deployments over a CVE nobody has fixed yet does not improve your security, it only stops you from shipping the fix for a different bug. What threshold is acceptable for your organization is decided by its security policy, not by this course.
- Keyless signing, SBOM and provenance
- uses: sigstore/cosign-installer@v3
- name: Sign the image (keyless, no keys to manage)
run: |
cosign sign --yes \
${{ env.REGISTRY }}/${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
env:
COSIGN_EXPERIMENTAL: "1"The keyless signing from 05-03 fits here like a glove. There is no private key to store, rotate or leak: Cosign asks GitHub for a short-lived OIDC token, gets an ephemeral certificate from Fulcio and records the signature in the public Rekor transparency log. The certificate expires within minutes; what remains is the proof, verifiable by anybody:
cosign verify ghcr.io/auroralibros/aurora-api:2.0.0 \
--certificate-identity-regexp '^https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comLook at what that identity actually verifies: it does not say "it is signed", it says "that workflow, from that repository, built it". An image signed from somebody's laptop does not pass that check. Together with the provenance: mode=max and the sbom: true from the previous step, you have the three pieces of the supply chain: what is inside it (SBOM), who built it and how (provenance), and that nobody has touched it since (the signature).
- The equivalent pipeline in GitLab CI
# .gitlab-ci.yml
stages: [tests, build, deploy]
variables:
IMAGE: $CI_REGISTRY_IMAGE/aurora-api
.docker: &docker # a YAML anchor reused by both jobs
image: docker:27-cli
services: ["docker:27-dind"] # Docker-in-Docker as a job service
variables: { DOCKER_TLS_CERTDIR: "/certs" }
tests:
<<: *docker
stage: tests
script:
- docker compose -f compose.tests.yaml up --abort-on-container-exit --exit-code-from tests
build:
<<: *docker
stage: build
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker buildx create --use --driver docker-container
script:
- |
docker buildx build --platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=$IMAGE:cache \
--cache-to type=registry,ref=$IMAGE:cache,mode=max \
--tag $IMAGE:$CI_COMMIT_REF_SLUG --tag $IMAGE:sha-$CI_COMMIT_SHORT_SHA --push ./api
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }, { if: $CI_COMMIT_TAG }]| Concept | GitHub Actions | GitLab CI |
|---|---|---|
| Executable unit | A job with steps |
A job with script |
| Grouping / ordering | needs: between jobs |
Sequential stages: |
| File | .github/workflows/*.yaml |
.gitlab-ci.yml |
| Reuse | uses: (marketplace actions) |
include:, extends:, YAML anchors |
| Job container | container: (optional) |
image: (the norm) |
| Build cache | type=gha |
type=registry |
| Built-in registry | ghcr.io | $CI_REGISTRY_IMAGE |
| Conditions | if: / on: |
rules: / only: |
| Secrets | Repository secrets | CI/CD variables (protected/masked) |
| Passwordless identity | Native OIDC | OIDC (id_tokens:) |
The cache is the most visible practical difference: GitHub has a backend of its own, whereas on GitLab the usual approach is to store the cache in the registry itself with type=registry, which has the bonus of working with any provider.
- Docker-in-Docker versus the mounted socket
To build images, the runner needs access to a daemon. There are three ways, and they are not equivalent in terms of security.
| Option | How | Risk | Performance |
|---|---|---|---|
DinD (docker:dind) |
A daemon nested inside the job | Requires --privileged: escape to the host if something goes wrong |
Cold cache every time |
| Mounted socket | -v /var/run/docker.sock:... |
The docker group = root on the host (05-03) |
A shared warm cache |
| BuildKit without a daemon | Rootless buildkitd or Kaniko |
The smallest: no privileges | Good, with a remote cache |
The first two rows say the same thing in different words: any job can take control of the runner. With DinD, because the privileged container has every capability. With the mounted socket, because whoever talks to the daemon can run docker run -v /:/host --privileged and read or modify the host's entire disk, including the credentials of every other pipeline.
In a private repository with trusted collaborators, either one is common practice. The moment you accept pull requests from outsiders, the mounted socket is unacceptable: a malicious PR that modifies the workflow walks off with your secrets. The alternatives are single-use ephemeral runners (what GitHub's hosted runners do) or building without a daemon using rootless BuildKit.
- Credential management: OIDC and ephemeral tokens
| Practice | Why | In Aurora Libros |
|---|---|---|
| Never in the repository | Git history is forever | Everything in the provider's secrets |
| An ephemeral token, not a password | It expires by itself; stealing it buys little | GITHUB_TOKEN per run |
| Minimum scope | A publishing token does not deploy | permissions: per job |
| OIDC instead of keys | There is no secret to rotate or leak | id-token: write for Cosign |
| Rotation and auditing | Detecting misuse | The registry's access log |
GITHUB_TOKEN is not a secret you created: GitHub generates it when the run starts, with exactly the permissions in the permissions: block, and invalidates it when it finishes. Stolen half an hour later, it is worth nothing.
The rule that is never broken is not printing a secret. Providers mask known values and show ***, but masking is easy to bypass: echo $KEY | base64 comes out in the clear, and a set -x in a bash script prints every command with its arguments. That is why credentials always go in through stdin (--password-stdin) and never as a command-line argument, which is also visible in the runner's process list.
- Automatic versioning from Git tags
git tag -a v2.0.0 -m "Separate probes, graceful shutdown and configuration validation"
git push origin v2.0.0 # this is, in practice, the publish buttonmetadata-action translates that tag into the image's four tags with no intervention:
| Git tag | Image tags | Does it move |
|---|---|---|
v2.0.0 |
2.0.0 |
Never |
v2.0.0 |
2.0 |
With every patch |
v2.0.0 |
2 |
With every minor version |
v2.0.0 |
latest |
With every version |
| (any build) | sha-a1b2c3d |
Never |
The two rows that never move are the ones for production; the moving ones are handy for development and dangerous in deployments. Aurora Libros' compose.prod.yaml pins the digest, so it does not even depend on 2.0.0 still pointing at the same thing.
- Automated deployment
deploy-staging:
needs: publish
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment: staging # lets you require approval and restrict secrets
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: deploy
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
set -euo pipefail
cd /opt/aurora-libros
export AURORA_API_DIGEST="${{ needs.publish.outputs.digest }}"
docker compose -f compose.prod.yaml pull
docker compose -f compose.prod.yaml up -d --wait --wait-timeout 120
docker compose -f compose.prod.yaml ps --format '{{.Name}} {{.Status}}'Four details make this a deployment rather than a roulette wheel. The --wait (which you were already using during onboarding) waits for the healthchecks to pass and returns an error if they do not, so a broken deployment turns the job red instead of silently leaving you with a downed service. The set -euo pipefail stops at the first line that fails. The deploy user is not root and can only touch that directory. And the deployment goes by digest, not by tag: the up -d starts exactly the image that has just passed the tests.
Even so, this is a single-machine deployment: for a few seconds the service is recreated and nobody is serving. From 06-03 onwards the final step stops being a remote docker compose up and becomes an instruction to the orchestrator, which replaces the replicas one at a time without interrupting service.
Common Mistakes and Tips
- Forgetting
--exit-code-from. The pipeline goes green with the tests in the red, which is worse than having no tests: it gives false confidence. Verify it on purpose by breaking a test. - Rebuilding the image in the deployment job. It destroys traceability and deploys something nobody has tested. You promote by digest.
- Building without a remote cache. A four-minute pipeline per change makes people stop pushing small commits, and that makes everything else worse.
- Scanning by tag instead of by digest. You analyze an image that may not be yours. Always use
image@sha256:.... exit-code: 1for every severity. The pipeline breaks every morning over a low CVE with no patch, and people learn to bypass the gate. Be strict where it matters.- Publishing from pull requests. Anybody who opens a PR can push an image to your registry: gate
push:on the event. And never pass secrets as arguments, since they show up inpsinside the runner and in plenty of logs; always--password-stdin. - Tip: make the pipeline reproducible locally. If
docker compose -f compose.tests.yaml upis the same thing CI runs, debugging a pipeline failure does not take twenty trial commits. - Tip: use
concurrencywithcancel-in-progress. Ten pushes in a row should not launch ten multi-architecture builds; only the last one matters.
Exercises
Exercise 1. Demonstrate the danger of --exit-code-from: deliberately break one of aurora-api's tests and run the test stack with and without that flag, comparing the exit codes. Explain what the pipeline would have done in each case.
Exercise 2. Measure the effect of the remote cache: run the pipeline three times (cold, cached, and cached after a change only in src/) and build the timing table. Explain why the third case is the fastest.
Exercise 3. Verify the complete supply chain of a published image: check the signature demanding that it comes from your workflow, extract the SBOM and locate the exact commit that generated it.
Solutions
Solution 1.
# An assertion is deliberately broken in api/test/books.test.js
docker compose -f compose.tests.yaml up --build --abort-on-container-exit
echo "without --exit-code-from: $?"
docker compose -f compose.tests.yaml up --build --abort-on-container-exit --exit-code-from tests
echo "with --exit-code-from: $?"tests-1 | FAIL test/books.test.js > returns the 9 titles in the catalog
tests-1 | AssertionError: expected 9 to equal 8
tests-1 exited with code 1
without --exit-code-from: 0
with --exit-code-from: 1The two exit codes, faced with exactly the same failing test, sum up the exercise:
| Run | Code | What CI would do |
|---|---|---|
Without --exit-code-from |
0 | Carries on: builds, signs and publishes the broken code |
With --exit-code-from tests |
1 | Stops at the test job; nothing gets published |
What is perverse about the first case is that the failure does appear in the log, AssertionError and all, but nobody reads it: the green check says everything is fine. Without the flag, docker compose up reports whether it managed to bring the stack up —and it did—, not whether the test container finished happy.
Hence a practice worth adopting: the first time you set up a pipeline, break it on purpose. A pipeline you have never seen fail is not a green pipeline, it is an unverified one.
Solution 2.
gh run list --workflow ci.yaml --limit 3 \
--json displayTitle,conclusion,createdAt,updatedAt \
--jq '.[] | "\(.displayTitle): \((.updatedAt|fromdate) - (.createdAt|fromdate))s"'fix: clearer error message (src/ only): 41s
chore: bump the pino version (package.json): 108s
ci: enable the gha cache (first run): 275s| Run | What changed | Build job time | Layers reused |
|---|---|---|---|
| 1st (cold) | Everything | 4 min 35 s | 0 |
| 2nd | package.json |
1 min 48 s | Base and system |
| 3rd | Only src/ |
41 s | Base, system and npm ci |
The third case is the fastest for the same reason you studied in 02-02, now applied to a different machine each time. The Dockerfile from 06-01 copies package.json and package-lock.json first, runs npm ci, and only then copies src/. If only the source code changes, the npm ci layer is still valid and BuildKit fetches it from the GitHub cache instead of reinstalling 180 packages.
What makes it possible for a brand-new runner to take advantage of the previous one's work is cache-from: type=gha: the runner is ephemeral, the cache is not. Without it, all three columns of the table would read 4 min 35 s.
One important nuance so the number does not mislead you: those 41 seconds cover two architectures. The arm64 build runs under QEMU emulation and is about three times slower than the native one; without cache, that fact alone would add more than two minutes.
Solution 3.
IMG=ghcr.io/auroralibros/aurora-api
DIG=$(docker buildx imagetools inspect $IMG:2.0.0 --format '{{.Manifest.Digest}}')
cosign verify $IMG@$DIG \
--certificate-identity-regexp '^https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com | jq '.[0].optional'{ "Issuer": "https://token.actions.githubusercontent.com",
"Subject": "https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@refs/tags/v2.0.0",
"githubWorkflowSha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"Bundle": { "Payload": { "logIndex": 148392017 } } }cosign download sbom $IMG@$DIG 2>/dev/null | jq -r '.packages[] | "\(.name) \(.versionInfo)"' | head -3
docker buildx imagetools inspect $IMG@$DIG \
--format '{{index .Image.Config.Labels "org.opencontainers.image.revision"}}'
# express 4.21.2
# pg 8.13.1
# ioredis 5.4.1
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0The checks answer different questions, and together they close the circle:
| Question | Mechanism | Evidence |
|---|---|---|
| Has anybody touched it? | The Cosign signature | Successful verification against Rekor |
| Who built it? | The certificate identity | The ci.yaml workflow at refs/tags/v2.0.0 |
| What is inside it? | The SBOM | 182 packages with name and version |
| Which code does it come from? | The OCI revision label |
Commit a1b2c3d… |
The decisive piece of data is the Subject, and it is worth pausing on: it does not say "somebody signed this image", it says which workflow, from which repository and from which Git reference built it. If an attacker managed to publish an image in your registry tagged 2.0.0, the signature would not verify against that identity and the deployment should reject it. That check is the one you automate in the cluster with an admission policy.
And the SBOM + revision pair is what turns a security alert into ten minutes of work: when the next critical CVE for a library is published, cosign download sbom tells you in seconds whether your image includes it and at which version, and the revision label takes you to the exact commit to apply the fix on.
Conclusion
The road from commit to published image is no longer one you walk yourself. You can tell continuous integration apart from continuous delivery and continuous deployment —three concepts and two acronyms— and you know why Aurora Libros does deployment to staging and delivery to production. You have internalized the rule that holds the whole module together: build once, deploy many times, because the artifact is the image and its digest is the same object in every environment; a docker build per environment is a design flaw, not a convenience.
The tests run against a real PostgreSQL and Redis in an ephemeral stack with its data in tmpfs, and you know that --abort-on-container-exit without --exit-code-from produces the worst possible outcome: a green pipeline over red tests, which you triggered on purpose in order to see it. The complete GitHub Actions workflow chains lint and tests, metadata-action translating v2.0.0 into four tags plus the unrepeatable sha-, build-push-action building for amd64 and arm64 with cache-to: type=gha,mode=max —which took the build from 4 min 35 s to 41 s when only src/ changes—, Trivy reporting everything and breaking only on criticals with a patch available, and Cosign's keyless signing that spares you custody of any private key. You have verified the entire chain from the outside: a valid signature, a Subject naming the workflow and the Git tag that produced it, an SBOM with the version of every package and the OCI revision label carrying the exact commit.
You also know the GitLab CI equivalent with its mapping table, the real trade-off between Docker-in-Docker and the mounted socket —where both options mean, put bluntly, that a job can take control of the runner—, and the credential rules: ephemeral tokens with per-job permissions, OIDC instead of static passwords and no secrets on the command line. The final step, for now, is a remote docker compose pull && up -d --wait over SSH deploying by digest.
And that is where the limit is. That deployment has a gap of a few seconds during which nobody serves, it runs on a single machine and, if that machine goes down, Aurora Libros disappears from the internet. In the next lesson, Orchestrating Containers with Docker Swarm, you will set up your first cluster: several nodes with managers and workers, overlay networks connecting containers on different machines, services that reschedule themselves when a node dies, and the compose.yaml you already know deployed as a stack with docker stack deploy.
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
