DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    CI/CD PipelinesLesson 6 of 12
    Your progress · 0%
    On this page
    1. From code to an image in a registry
    2. A Dockerfile for CI
    3. Tags and digests
    4. Pushing to a registry
    5. Other artifacts: Nexus and Artifactory
    ↖ Course roadmap
    Lesson 6 · Build & publish

    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.

    40 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 6 complete

    From 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:

    Build with cached layers, tag with the version and commit, push once. Tags are names; the digest is the image.© Diagram: DevOps Academy

    Build with cached layers, tag with the version and commit, push once. Tags are names; the digest is the image.© Diagram: DevOps Academy

    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
    
    ChoiceWhy
    Two stagesESLint, test tools and npm's cache stay in deps; the final image gets only node_modules for production
    npm ci --omit=devthe exact lockfile, without dev dependencies
    USER nodethe official Node image ships a non-root node user; a compromised app isn't root in the container
    HEALTHCHECKDocker (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 containers
    • buildbuild 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:dev
    • runstart 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 inside
    • shop-api:devthe image to start
    curl -s localhost:3300/health
    • curlmake an HTTP request
    • -ssilent: no progress bar
    docker exec shop id
    • execrun a command in a running container
    • shopthe container
    • idthe 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 containers
    • buildbuild 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 -7
    • dockerbuild and run containers
    • historylist an image’s layers
    • shop-api:devthe image
    • --format '{{.Size}}\t{{.CreatedBy}}'print only these fields (Go template)
    • |pipe: send output to the next command
    • headshow 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?":

    TagMoves?Use
    1.0.42-e3b0c44never (by rule)deploy this: the version and the exact commit
    1.0to each new 1.0.xpeople who want the latest patch
    latestevery push to mainhumans 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-e3b0c44
    • dockerbuild and run containers
    • pushupload an image to a registry
    • localhost:5500/shop-api:1.0.42-e3b0c44the image
    curl -s localhost:5500/v2/shop-api/tags/list
    • curlmake an HTTP request
    • -ssilent: no progress bar
    docker pull localhost:5500/shop-api@sha256:b8c8adcdc2b1291b99af67accca49c584081f17e48fe2982a1644dcc653b09be
    • pulldownload an image
    • localhost: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:3
    • dockerbuild and run containers
    • runstart a new container
    • -drun in the background
    • --name registryname the container
    • -p 5500:5000publish a port, host:container
    • registry:3the image to start
    docker tag shop-api:dev localhost:5500/shop-api:1.0.42-e3b0c44
    • taggive an image another name
    • shop-api:devthe image, then its new name
    • localhost:5500/shop-api:1.0.42-e3b0c44the image, then its new name
    docker push localhost:5500/shop-api:1.0.42-e3b0c44
    • pushupload an image to a registry
    • localhost: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
    
    PieceDoes
    metadata-actionturns 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=ghalayers survive between runs, so CI gets the same CACHED steps you saw locally
    packages: writeonly 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:

    KindExamplesGood for
    Universal artifact repositorySonatype Nexus, JFrog Artifactorymany formats in one place, private package registries, proxies and caches of public ones
    Language or cloud registriesGitHub Packages, npm, PyPI, ECR, Artifact Registryone 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.

    In production

    Deploy by version tag or digest, never latest. Scan images before they're promoted (Trivy, Grype, or the registry's own scanner) and fail on critical vulnerabilities. Rebuild regularly so base-image security fixes reach you. Set retention rules in the registry, or old images pile up and cost money.

    ⌘
    Mini mission

    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.

    Input: Docker, and the shop-api repository from the earlier lessonsOutput: localhost:5500/shop-api with three tags and one digest
    Reveal 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 containers
    • buildbuild 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 12
    • runstart a new container
    • -drun in the background
    • --name shopname the container
    • -p 3300:3000publish a port, host:container
    • shop-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 shop
    • pslist running containers
    • --filter name=shopshow only matching items
    • --format '{{.Status}}'print only these fields (Go template)
    • execrun a command in a running container
    • shopthe container
    • idthe command to run inside it
    • rmdelete 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:container
    • registry: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; done
    • forloop over a list
    • dostart of what the loop runs each time
    • taggive an image another name
    • shop-api:devthe image, then its new name
    • localhost:5500/shop-api:$t;the image, then its new name
    • dockerthe image, then its new name
    • pushthe image, then its new name
    • -qprint IDs only
    • doneend of the loop
    curl -s localhost:5500/v2/shop-api/tags/list
    • curlmake 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 command
    • grepfind lines that match a pattern
    • -iignore upper/lower case
    actionlint && git add . && git commit -m "ci: build and publish the image" && git push
    • gitversion control
    • addstage changes
    • .this folder
    • commitsave staged changes
    • pushupload 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    Why copy package.json and package-lock.json and run npm ci before copying the source code?

    Go deeper

    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.

    ← PreviousTesting in pipelines: lint, unit, integration and the gateNext lesson →Deploy: static sites, servers over SSH, Kubernetes, and rollbacks