Skip to content

Image Distribution Example Part 2


Part 1 left you with calculator:1.0.0, built and tested locally. This part puts a registry in front of it — one you run yourself — and takes the image through three real release cycles using the tagging scheme from the versioning section, driven by a small script instead of typed-by-hand commands.

Running a Private Registry

Docker Hub is a registry someone else runs. Nothing stops you from running the exact same software yourself — the reference registry implementation is itself just a container:

docker container run -d \
  -p 5000:5000 \
  --name local-registry \
  --mount type=volume,source=registry-data,target=/var/lib/registry \
  registry:2

That’s a full, working registry, listening on port 5000.

The --mount type=volume,source=registry-data,target=/var/lib/registry mount is worth noticing — it’s the same volume pattern from the Storage section, and for the same reason: without it, every image you push lives only in the registry container’s writable layer, and disappears the moment that container is removed.

Why localhost:5000 Needs No Extra Setup

Docker normally refuses to talk to a registry over anything but HTTPS — but it makes one specific exception: addresses that resolve to localhost or 127.0.0.1 are allowed over plain HTTP with zero configuration. That’s the entire reason this works immediately (don’t run yet):

docker image push localhost:5000/calculator:1.0.0

No login, no certificate, no config file — Docker recognizes the loopback address and trusts it implicitly. This is genuinely convenient for learning and local development, and it’s exactly why the loopback exception exists at all.

Warning

This convenience is specific to localhost. It does not extend to any other address, including your machine’s LAN IP or hostname — even on the same network, from a different computer. The moment this registry needs to be reached by anything other than the machine it’s running on, the loopback exception no longer applies, and you’re into real infrastructure territory:

  • TLS: put a real certificate in front of the registry (self-signed for internal-only use, or one from a proper CA if it needs to be trusted broadly), or
  • Explicit trust: tell every Docker daemon that needs to reach it “trust this specific address over plain HTTP anyway,” via an insecure-registries entry in that machine’s /etc/docker/daemon.json — a deliberate, per-machine exception you’d only make for a registry you fully control on a network you trust.

Neither of those is set up in this exercise — this stays on localhost throughout, on purpose, to keep the focus on registry mechanics and versioning rather than certificate management.

One more difference from Docker Hub worth flagging: this registry has no authentication at all. Anyone who can reach port 5000 can push or pull, no docker login involved. That’s fine for a local learning exercise; it is not how you’d run one for anything real — a production private registry sits behind authentication, same as Docker Hub does.

Registry Navigation That Will Help You Later

registry:2 has no built-in web UI — everything about what’s stored in it is exposed through a plain HTTP API instead, which means curl is really all you need to look inside it.

List every repository the registry knows about:

curl http://localhost:5000/v2/_catalog
{"repositories":["calculator"]}

List the tags pushed for a given repository:

curl http://localhost:5000/v2/calculator/tags/list
{"name":"calculator","tags":["1.0.0","1.1.0","1.1.1","1.1","1","latest"]}

This is a genuinely useful sanity check after running release.sh — a quick way to confirm all four tags actually landed, without pulling anything.

Fetch a specific tag’s manifest (the same digest/layer data docker image inspect shows you locally):

curl http://localhost:5000/v2/calculator/manifests/1.0.0

Note

Deleting a tag through this API works, but it’s not a single friendly command — it requires the manifest’s digest (not the tag name) and only functions at all if the registry container was started with -e REGISTRY_STORAGE_DELETE_ENABLED=true, which the one you’re running wasn’t. This is a case where a real setup usually reaches for a UI on top of the API rather than deleting by hand — tools like joxit/docker-registry-ui run as a companion container and give you a browsable page listing repositories, tags, and a delete button, talking to the same API underneath.

For everything this course covers, the _catalog and tags/list endpoints are enough to answer “what’s actually in there right now” — which is usually the only question that comes up during normal use.

A Release Script, So Nothing Gets Typed Twice

Rather than retyping four tag names by hand on every release — and risking a typo on one of them — the version number lives in exactly one place, and every tag is derived from it.

Save this as release.sh:

#!/bin/sh
set -e

VERSION=1.0.0
REGISTRY=localhost:5000
IMAGE=calculator

EXISTING=$(curl -s "http://$REGISTRY/v2/$IMAGE/tags/list" | grep -o "\"$VERSION\"")
if [ -n "$EXISTING" ]; then
    echo "ERROR: $IMAGE:$VERSION already exists on the registry. Bump VERSION before releasing again."
    exit 1
fi

MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
MAJOR=$(echo "$VERSION" | cut -d. -f1)

docker image build \
  -t "$REGISTRY/$IMAGE:$VERSION" \
  -t "$REGISTRY/$IMAGE:$MAJOR_MINOR" \
  -t "$REGISTRY/$IMAGE:$MAJOR" \
  -t "$REGISTRY/$IMAGE:latest" \
  .

docker image push "$REGISTRY/$IMAGE:$VERSION"
docker image push "$REGISTRY/$IMAGE:$MAJOR_MINOR"
docker image push "$REGISTRY/$IMAGE:$MAJOR"
docker image push "$REGISTRY/$IMAGE:latest"

echo "Released $REGISTRY/$IMAGE:$VERSION"
echo "  also pushed as: $MAJOR_MINOR, $MAJOR, latest"

set -e makes the script stop immediately if any command fails, rather than plowing ahead and pushing a half-built release. MAJOR_MINOR and MAJOR are computed from VERSION with cut, not typed separately — so there’s exactly one number to get right, not four. docker image build takes all four -t flags in a single command, meaning there’s no separate re-tagging step afterward where one tag could quietly get missed.

Make it executable:

chmod +x release.sh

Release 1: v1.0.0

Run the script as-is — it’s already set to VERSION=1.0.0, matching Part 1’s build.

./release.sh

Confirm all four tags actually landed on the registry:

curl http://localhost:5000/v2/calculator/tags/list | jq

This will give you pretty json:

{
  "name": "calculator",
  "tags": [
    "latest",
    "1.0",
    "1",
    "1.0.0"
  ]
}

Run it and test the one-shot behavior from Part 1, now via the registry image instead of the locally built one:

docker container run -d -p 8080:8080 --name calc-v1 localhost:5000/calculator:1.0.0
echo "add 2 3" | nc -q 1 localhost 8080

You should get 5.00 back — and, as before, the container has now exited, since this version still handles exactly one connection. You can remove the container:

docker container rm -f calc-v1

Release 2: v1.1.0 — Adding the Loop

This is the change flagged back in Part 1: the server currently exits after one connection, which is fine for testing but not for anything meant to stay up. Wrapping the connection-handling logic in a loop is a new capability — nothing about how existing clients use the server changes — so this is a MINOR bump, not a major one.

Replace calculator.c with this version:

calculator.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <port>\n", argv[0]);
        return 1;
    }
    int port = atoi(argv[1]);

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); return 1; }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(port);

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind"); return 1;
    }
    if (listen(server_fd, 1) < 0) {
        perror("listen"); return 1;
    }

    printf("calculator-server listening on port %d\n", port);

    while (1) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (client_fd < 0) { perror("accept"); continue; }

        char buffer[256] = {0};
        read(client_fd, buffer, sizeof(buffer) - 1);

        char op[16];
        double a, b, result;
        int parsed = sscanf(buffer, "%15s %lf %lf", op, &a, &b);

        char response[256];
        if (parsed != 3) {
            snprintf(response, sizeof(response), "ERROR invalid request\n");
        } else if (strcmp(op, "add") == 0) {
            result = a + b;
            snprintf(response, sizeof(response), "%.2f\n", result);
        } else if (strcmp(op, "subtract") == 0) {
            result = a - b;
            snprintf(response, sizeof(response), "%.2f\n", result);
        } else if (strcmp(op, "multiply") == 0) {
            result = a * b;
            snprintf(response, sizeof(response), "%.2f\n", result);
        } else if (strcmp(op, "divide") == 0) {
            result = a / b;
            snprintf(response, sizeof(response), "%.2f\n", result);
        } else {
            snprintf(response, sizeof(response), "ERROR unknown operation '%s'\n", op);
        }

        write(client_fd, response, strlen(response));
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

The only structural change: accept() through close(client_fd) is now inside while (1), and server_fd is no longer closed after one round — the server keeps listening indefinitely instead of exiting.

Update release.sh: change the top line to VERSION=1.1.0, then run it again.

./release.sh

Verify the rolling tags actually moved, and the pinned one didn’t:

docker container run -d -p 8080:8080 --name calc-v1_1 localhost:5000/calculator:latest
echo "add 10 5" | nc -q 1 localhost 8080
echo "multiply 3 4" | nc -q 1 localhost 8080

Both requests should succeed against the same container — proof the loop is working, since v1.0.0 could never have answered a second request.

docker container rm -f calc-v1_1
docker container run -d -p 8080:8080 --name calc-pinned localhost:5000/calculator:1.0.0
echo "add 1 1" | nc -q 1 localhost 8080
echo "add 2 2" | nc -q 1 localhost 8080
docker container rm -f calc-pinned

The second echo against calc-pinned should fail to get a response — 1.0.0 still behaves exactly as it did on release day, unaffected by everything pushed after it.

Release 3: v1.1.1 — Fixing Divide

Try dividing by zero against the current version before fixing anything:

docker container run -d -p 8080:8080 --name calc-buggy localhost:5000/calculator:latest
echo "divide 5 0" | nc -q 1 localhost 8080
docker container rm -f calc-buggy

Floating-point division by zero in C doesn’t crash — it silently returns inf, which is arguably worse: no error, no warning, just a nonsensical number that could get used downstream as if it were valid. This is a correctness fix with no change to how the server is used — a PATCH.

In calculator.c, replace the divide branch:

        } else if (strcmp(op, "divide") == 0) {
            if (b == 0) {
                snprintf(response, sizeof(response), "ERROR division by zero\n");
            } else {
                result = a / b;
                snprintf(response, sizeof(response), "%.2f\n", result);
            }
        } else {

Bump release.sh to VERSION=1.1.1, and release again:

./release.sh

Confirm the fix, and confirm — one more time — that the earlier pinned version still hasn’t moved:

docker container run -d -p 8080:8080 --name calc-fixed localhost:5000/calculator:latest
echo "divide 5 0" | nc -q 1 localhost 8080
docker container rm -f calc-fixed

docker image inspect localhost:5000/calculator:1.0.0 --format '{{.RepoDigests}}'
docker image inspect localhost:5000/calculator:1.1.1 --format '{{.RepoDigests}}'

Three releases in, and 1.0.0 has a digest that’s never changed since the moment it was first pushed — exactly the guarantee semantic versioning and immutable tags are meant to provide.

Cleanup

docker container rm -f calc-v1 calc-v1_1 calc-pinned calc-buggy calc-fixed 2>/dev/null
docker image rm calculator:1.0.0
docker image rm $(docker image ls localhost:5000/calculator -q) 2>/dev/null
docker container rm -f local-registry
docker volume rm registry-data

That last pair removes the registry container and the volume backing it — meaning everything pushed during this exercise is now genuinely gone, both locally and from the registry itself. If you wanted to keep the registry running for further practice, skip those last two lines.

The registry image and all other calculator images are still there in your host. If you want to remove them too:

docker image rm -f $(docker image ls -aq)

Warning

This cleanup is intended for this exercise only, not production server that you care of.

What You’ve Actually Built

Three real releases, each with an honest reason for its version number, each pushed through a script that made typos structurally difficult rather than just “be careful.” That’s the whole point of everything this Image Distribution section has covered — not memorizing docker image push syntax, but ending up with a release process where forgetting a tag or shipping the wrong version takes real effort instead of being one missed step away at all times.

Last updated on