Image Distribution Example Part 1
Everything in this section so far has used images someone else built. This is where that changes: a small calculator server, built from scratch with a Dockerfile, versioned properly, and pushed to a registry you run yourself. This first part covers building the image correctly — the registry and versioning work comes next.
The App: A Tiny Calculator Server
The calculator is a small C program that listens on a TCP port, accepts one connection, reads a request like add 2 3, computes the result, and writes it back. No external libraries — just the C standard library and POSIX sockets, both included in any base image with a C compiler.
Note
You are learning how image is distributed not C. So, it doesn’t matter if you know C or not. You can continue in either case.
Create calculator.c in my-project folder with the given code:
mkdir my-project && cd my-project#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);
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd < 0) { perror("accept"); return 1; }
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;
}A quick walkthrough for anyone who hasn’t written C before:
argv[1]is the port number, passed in when the program starts — this is whatCMDwill supply later.socket(),bind(), andlisten()set up a listening TCP socket on that port. This is the exact same networking layer your OS uses for any server — nothing Docker-specific about it.accept()blocks until one client connects, then hands back a separate file descriptor (client_fd) just for talking to that one client.read()pulls in whatever the client sent — expected to look likeadd 2 3.sscanf()splits that into an operation name and two numbers.- The
if/else ifchain picks the operation and computes the result. Noticedividehas no check forbbeing zero yet — that’s intentional, and gets fixed as a deliberate bug fix once versioning is in the picture. write()sends the result back, then both sockets are closed and the program exits.
That last point matters: this version handles exactly one connection and then the process ends — which means the container stops right after. That’s a deliberate first version, not an oversight; it becomes relevant once you start versioning this app for real.
Save this as calculator.c.
The Multi-Stage Dockerfile
You’ve already covered why multi-stage builds exist, so just the specifics here: the build stage needs gcc and the C standard headers to compile the program; the final image doesn’t need any of that; it only needs the compiled binary. Create this Dockerfile alongside your code:
# ---- Build stage ----
FROM alpine:3.19 AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /build
COPY calculator.c .
RUN gcc -static -o calculator-server calculator.c
# ---- Final stage ----
FROM alpine:3.19
COPY --from=builder /build/calculator-server /calculator-server
EXPOSE 8080
ENTRYPOINT ["/calculator-server"]
CMD ["8080"]gcc -static links the C standard library directly into the binary instead of depending on it being present at runtime. Combined with the multi-stage split, the final image doesn’t need gcc, musl-dev, or even a working C library installed — just the one binary sitting on top of a bare alpine. Build it and compare, to see the difference for yourself:
docker image build --target builder -t calculator:builder-stage .
docker image build -t calculator:1.0.0 .
docker image ls calculatorThe builder-stage tag (built with --target to stop at that stage) will be noticeably larger than the final 1.0.0 image — that’s the entire payoff of the multi-stage split, made visible.
Why Both ENTRYPOINT and CMD
The Dockerfile above uses both ENTRYPOINT and CMD together, which is worth pausing on — the difference between them is easy to memorize as a rule and still not understand until you see what each one is actually for.
ENTRYPOINTis the program that always runs when the container starts. It’s not meant to be casually overridden — if it were, someone could accidentally run the image without starting the server at all.CMDsupplies the default arguments passed to that entrypoint. UnlikeENTRYPOINT, it’s specifically designed to be overridden at run time.
Try both, back to back:
docker container run -d -p 8080:8080 --name calc-default calculator:1.0.0With no extra arguments, Docker runs /calculator-server 8080 — the entrypoint, plus CMD’s default port.
docker container run -d -p 9090:9090 --name calc-custom calculator:1.0.0 9090Here, 9090 at the end of the command replaces CMD’s ["8080"] — Docker now runs /calculator-server 9090. The entrypoint itself — which program runs — never changed; only the argument fed into it did.
This is the actual reason real-world Dockerfiles so often define both: ENTRYPOINT locks down what the image fundamentally does, CMD exposes the one thing a user should reasonably be allowed to change — here, which port to listen on — without needing to know or override the entrypoint at all.
Testing Calculator App
Try the server for each container to confirm both are actually listening where expected. You can directly run this command on host machine because ports are mapped.
For the first container listening on default 8080:
echo "add 2 3" | nc -q 1 localhost 8080For the second container listening on 9090:
echo "subtract 22 13" | nc -q 1 localhost 9090Note
Since this version of calculator-server only handles one connection before exiting, each container answers exactly once — try it again against the same container and the connection will simply fail, because the process has already exited. That’s expected, and it’s exactly what the next part starts to change.
Cleanup Before Moving On
Remove image and containers:
docker container rm -f calc-default calc-custom
docker image rm calculator:builder-stageKeep calculator:1.0.0 around — the next part picks up right here, versioning and pushing this exact image to a registry you’ll run yourself.