Build and publish: images you can trust and find again
Turn shop-api into a Docker image in CI: a small, cached, non-root build, tags that say exactly what is inside, pushing to a registry, digests that never change, and where other artifacts live.
By the end of this lesson you'll be able to
0 of 6 completeFrom code to an image in a registry
Lesson 3 packaged shop-api as a .tgz. For anything that runs in containers, the artifact you build once and
deploy everywhere is an image, stored in a registry:
A Dockerfile for CI
# syntax=docker/dockerfile:1 # 1. Install production dependencies in a throwaway stage FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev # 2. The image we ship: runtime, dependencies and code, nothing else FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production PORT=3000 COPY --from=deps /app/node_modules ./node_modules COPY package.json ./ COPY src ./src USER node EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "src/server.js"]
# .dockerignore: never sent to the build node_modules .git .github test *.md .env
| Choice | Why |
|---|---|
| Two stages | ESLint, test tools and npm's cache stay in deps; the final image gets only node_modules for production |
npm ci --omit=dev | the exact lockfile, without dev dependencies |
USER node | the official Node image ships a non-root node user; a compromised app isn't root in the container |
HEALTHCHECK | Docker (and docker ps) know whether the app actually answers |
.dockerignore | .env and .git never reach the build, so they can't end up in a layer |
CMD [...] (exec form) | node is PID 1 and receives stop signals directly |
$ docker build -t shop-api:dev . $ docker run -d --name shop -p 3300:3000 -e APP_VERSION=1.0.42-e3b0c44 shop-api:dev $ curl -s localhost:3300/health {"status":"ok","version":"1.0.42-e3b0c44"} $ docker exec shop id uid=1000(node) gid=1000(node) groups=1000(node) $ docker ps --filter name=shop --format '{{.Status}}' Up 14 seconds (healthy)
docker build -t shop-api:dev .dockerbuild and run containersbuildbuild an image from a Dockerfile-t shop-api:devname and tag the image.the build context: the files COPY can see
docker run -d --name shop -p 3300:3000 -e APP_VERSION=1.0.42-e3b0c44 shop-api:devrunstart a new container-drun in the background--name shopname the container-p 3300:3000publish a port, host:container-e APP_VERSION=1.0.42-e3b0c44set an environment variable insideshop-api:devthe image to start
curl -s localhost:3300/healthcurlmake an HTTP request-ssilent: no progress bar
docker exec shop idexecrun a command in a running containershopthe containeridthe command to run inside it
docker ps --filter name=shop --format '{{.Status}}'pslist running containers--filter name=shopshow only matching items--format '{{.Status}}'print only these fields (Go template)
Layer caching: order matters
Every instruction makes a layer, and Docker reuses a cached layer until something it depends on changes.
Change one line in src/ and rebuild:
$ docker build --progress=plain -t shop-api:dev . #11 [deps 4/4] RUN npm ci --omit=dev #11 CACHED #13 [stage-1 5/5] COPY src ./src
docker build --progress=plain -t shop-api:dev .dockerbuild and run containersbuildbuild an image from a Dockerfile-t shop-api:devname and tag the image.the build context: the files COPY can see
npm ci is CACHED: only the COPY src layer and those after it are rebuilt. Put COPY . . before
npm ci instead, and every code change reinstalls everything. On this machine that took 16.3 s against 13.0 s,
a small gap only because shop-api has no production dependencies; with a few hundred packages it's minutes on
every build.
What's actually in the image?
$ docker history shop-api:dev --format '{{.Size}}\t{{.CreatedBy}}' | head -7 0B CMD ["node" "src/server.js"] 0B HEALTHCHECK {Test:[CMD-SHELL wget -qO- http:… 0B EXPOSE [3000/tcp] 0B USER node 20.5kB COPY src ./src # buildkit 12.3kB COPY package.json ./ # buildkit 36.9kB COPY /app/node_modules ./node_modules # buildkit
docker history shop-api:dev --format '{{.Size}}\t{{.CreatedBy}}' | head -7dockerbuild and run containershistorylist an image’s layersshop-api:devthe image--format '{{.Size}}\t{{.CreatedBy}}'print only these fields (Go template)|pipe: send output to the next commandheadshow the first lines
Our layers add about 70 kB. The other 238 MB is node:22-alpine itself, mostly the Node binary. Image size is
decided by the base image far more than by your code: -alpine and -slim variants are small, distroless
images smaller still, and every byte you don't ship is a byte with no vulnerabilities.
Tags and digests
A tag is a movable name for an image. Give each build tags that answer "what is this?":
| Tag | Moves? | Use |
|---|---|---|
1.0.42-e3b0c44 | never (by rule) | deploy this: the version and the exact commit |
1.0 | to each new 1.0.x | people who want the latest patch |
latest | every push to main | humans trying it out; never deploy it |
A digest is the image's content hash, and it can't move:
$ docker push localhost:5500/shop-api:1.0.42-e3b0c44 1.0.42-e3b0c44: digest: sha256:b8c8adcdc2b1291b99af67accca49c584081f17e48fe2982a1644dcc653b09be size: 856 $ curl -s localhost:5500/v2/shop-api/tags/list {"name":"shop-api","tags":["1.0","1.0.42-e3b0c44","latest"]} $ docker pull localhost:5500/shop-api@sha256:b8c8adcdc2b1291b99af67accca49c584081f17e48fe2982a1644dcc653b09be
docker push localhost:5500/shop-api:1.0.42-e3b0c44dockerbuild and run containerspushupload an image to a registrylocalhost:5500/shop-api:1.0.42-e3b0c44the image
curl -s localhost:5500/v2/shop-api/tags/listcurlmake an HTTP request-ssilent: no progress bar
docker pull localhost:5500/shop-api@sha256:b8c8adcdc2b1291b99af67accca49c584081f17e48fe2982a1644dcc653b09bepulldownload an imagelocalhost:5500/shop-api@sha256:b8c8adcdc2b1291b99af67accca49c584081f17e48fe2982a1644dcc653b09bethe image
All three tags return the same Docker-Content-Digest. Deploying image@sha256:… guarantees production runs
exactly the bytes you tested, even if someone re-pushes a tag. Kubernetes and GitOps tools can pin digests too.
Pushing to a registry
A registry stores images: Docker Hub, GitHub Container Registry (ghcr.io), Amazon ECR, Google Artifact
Registry, Azure Container Registry, or your own. You can run one locally in a second to practise:
$ docker run -d --name registry -p 5500:5000 registry:3 $ docker tag shop-api:dev localhost:5500/shop-api:1.0.42-e3b0c44 $ docker push localhost:5500/shop-api:1.0.42-e3b0c44
docker run -d --name registry -p 5500:5000 registry:3dockerbuild and run containersrunstart a new container-drun in the background--name registryname the container-p 5500:5000publish a port, host:containerregistry:3the image to start
docker tag shop-api:dev localhost:5500/shop-api:1.0.42-e3b0c44taggive an image another nameshop-api:devthe image, then its new namelocalhost:5500/shop-api:1.0.42-e3b0c44the image, then its new name
docker push localhost:5500/shop-api:1.0.42-e3b0c44pushupload an image to a registrylocalhost:5500/shop-api:1.0.42-e3b0c44the image
In CI, the Docker actions do the build, the tags and the push, with the layer cache stored in GitHub's cache:
name: Image on: push: branches: [main] tags: ['v*'] pull_request: permissions: contents: read jobs: build: runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read packages: write # push to ghcr.io steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 if: github.event_name != 'pull_request' with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - uses: docker/build-push-action@v7 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max
| Piece | Does |
|---|---|
metadata-action | turns Git into tags: v1.0.42 gives 1.0.42 and 1.0; every build gets sha-e3b0c44; main gets latest |
push: … != 'pull_request' | pull requests build (proving the Dockerfile works) but never publish |
cache-from/to: type=gha | layers survive between runs, so CI gets the same CACHED steps you saw locally |
packages: write | only this job may push |
Pull requests from forks get a read-only token and no secrets, which is one more reason they shouldn't push.
Other artifacts: Nexus and Artifactory
Not everything is an image. JAR files, npm and Python packages, Helm charts, binaries and build reports need a home too. Two kinds of storage cover it:
| Kind | Examples | Good for |
|---|---|---|
| Universal artifact repository | Sonatype Nexus, JFrog Artifactory | many formats in one place, private package registries, proxies and caches of public ones |
| Language or cloud registries | GitHub Packages, npm, PyPI, ECR, Artifact Registry | one format, close to where it's used |
Companies run Nexus or Artifactory for two reasons beyond storage. As a proxy of npm, Maven Central or PyPI, it caches every package your builds download, so builds keep working when the public registry is slow or a package disappears. And as a gate, it lets security teams block known-bad packages for every team at once. The workflow is the same as with a registry: CI builds once, publishes a versioned artifact, and every later stage downloads that exact version.
Ship shop-api as an image
Add the Dockerfile and .dockerignore to shop-api, build it, prove it runs as a non-root user and reports healthy, push it under a version tag, a minor tag and latest to a local registry, and show all three tags are one digest. Then add the image workflow.
localhost:5500/shop-api with three tags and one digestReveal one safe solution
$ nano Dockerfile .dockerignore # from this lesson $ docker build -t shop-api:dev . $ docker run -d --name shop -p 3300:3000 shop-api:dev && sleep 12 $ docker ps --filter name=shop --format '{{.Status}}' && docker exec shop id && docker rm -f shop $ docker run -d --name registry -p 5500:5000 registry:3 $ V=1.0.42-$(git rev-parse --short=7 HEAD) $ for t in $V 1.0 latest; do docker tag shop-api:dev localhost:5500/shop-api:$t; docker push -q localhost:5500/shop-api:$t; done $ curl -s localhost:5500/v2/shop-api/tags/list $ for t in $V 1.0 latest; do curl -sI -H "Accept: application/vnd.oci.image.index.v1+json" localhost:5500/v2/shop-api/manifests/$t | grep -i digest; done $ nano .github/workflows/image.yml # from this lesson $ actionlint && git add . && git commit -m "ci: build and publish the image" && git push
docker build -t shop-api:dev .dockerbuild and run containersbuildbuild an image from a Dockerfile-t shop-api:devname and tag the image.the build context: the files COPY can see
docker run -d --name shop -p 3300:3000 shop-api:dev && sleep 12runstart a new container-drun in the background--name shopname the container-p 3300:3000publish a port, host:containershop-api:devthe image to start&&run the next command only if this one worked
docker ps --filter name=shop --format '{{.Status}}' && docker exec shop id && docker rm -f shoppslist running containers--filter name=shopshow only matching items--format '{{.Status}}'print only these fields (Go template)execrun a command in a running containershopthe containeridthe command to run inside itrmdelete a container-fstop it first if running
docker run -d --name registry -p 5500:5000 registry:3--name registryname the container-p 5500:5000publish a port, host:containerregistry:3the image to start
for t in $V 1.0 latest; do docker tag shop-api:dev localhost:5500/shop-api:$t; docker push -q localhost:5500/shop-api:$t; doneforloop over a listdostart of what the loop runs each timetaggive an image another nameshop-api:devthe image, then its new namelocalhost:5500/shop-api:$t;the image, then its new namedockerthe image, then its new namepushthe image, then its new name-qprint IDs onlydoneend of the loop
curl -s localhost:5500/v2/shop-api/tags/listcurlmake an HTTP request-ssilent: no progress bar
for t in $V 1.0 latest; do curl -sI -H "Accept: application/vnd.oci.image.index.v1+json" localhost:5500/v2/shop-api/manifests/$t | grep -i digest; done-Iheaders only|pipe: send output to the next commandgrepfind lines that match a pattern-iignore upper/lower case
actionlint && git add . && git commit -m "ci: build and publish the image" && git pushgitversion controladdstage changes.this foldercommitsave staged changespushupload commits
The status shows (healthy), id shows uid=1000(node), and the three Docker-Content-Digest lines are
identical. On GitHub, the run's image appears under the repository's Packages with sha-<commit> and
latest; tag a release v1.0.42 and it gains 1.0.42 and 1.0.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
Why copy package.json and package-lock.json and run npm ci before copying the source code?
Official reading for this lesson
- DocsDocker Docs: multi-stage buildsDocker Docs
- DocsDocker Docs: optimizing builds with cacheDocker Docs
- DocsDocker Docs: cache storage backends (GitHub Actions cache)Docker Docs
- Tooldocker/metadata-action: tags and labels from GitDocker
- DocsGitHub Docs: working with the Container registryGitHub Docs
- DocsSonatype Nexus Repository documentationSonatype
Or get every quiz answer right and it completes itself.