Build vs runtime in containers — I finally understood it through Hugo

For some time now, my website has been running inside a container. It is not a huge system, just a static website built with Hugo and served by Nginx. A simple setup: Hugo generates HTML, CSS and the rest of the files, and Nginx serves them to the outside world.

Nothing special at first glance, but while working with container images, I started to better understand a very important distinction: build and runtime.

Of course, I already knew that multi-stage Dockerfiles and Containerfiles existed. I also knew that you could build something in one stage and copy the result into another. But only when I looked at my own Hugo-based website did I really see how practical and sensible this approach is.

Build, or the workshop

For me, the build stage is like a workshop.

This is where I need tools. In the case of Hugo, I need Hugo itself, the website source files, configuration, theme, Markdown files and everything else required to generate the final result.

In a simplified form, it looks like this:

Markdown + theme + config
      Hugo
     public/

Hugo takes my source files and generates the public/ directory. This is where the finished website lives: HTML, CSS, JavaScript, images and other static files.

At this stage, the image may contain additional tools. It can have curl, tar, a package manager, a shell and everything needed to prepare the website.

But this is only the workshop. It is needed to build the product, not to run it later.

Runtime, or the finished product

Runtime is a completely different story.

If Hugo has generated the public/ directory, then the final container no longer needs Hugo. It also does not need Markdown files, website sources, build configuration or tools for downloading packages.

Runtime has only one job: serve the generated files.

So in my case, the final container should contain:

nginx + /usr/share/nginx/html/

And that is it.

It does not need Hugo. It does not need curl. It does not need tar. It does not need a package manager. It does not need the whole repository. It does not need a “workshop in the living room”.

I really liked this comparison:

build   = workshop with tools
runtime = finished product

In production, I do not keep a concrete mixer, a drill and scaffolding around. In production, the finished house should be standing.

Why does this make sense?

The fewer things in the final container, the better.

First, the image is simpler. It contains only what is actually needed for the service to run.

Second, the attack surface is smaller. If there is no shell, no package manager, no compilers and no random tools inside the container, a potential attacker has fewer options to work with.

Third, fewer packages means fewer potential vulnerabilities. Security scanners do not have to complain about half of an operating system that my static website does not need anyway.

Fourth, it enforces discipline. There is no manually fixing things inside a running container. If something needs to change, I change the Dockerfile or Containerfile, build a new image and deploy it again.

This is an approach that makes more and more sense to me:

A container is not a small virtual machine.
A container is a package for a specific service.

Example with Hugo

My Hugo setup can be reduced to two stages.

The first stage builds the website:

FROM alpine:3.20 AS builder

ARG ENV=dev
ARG HUGO_VERSION=0.147.5
ARG HUGO_ARCH=linux-arm64

RUN apk --no-cache add curl tar ca-certificates \
  && curl -L -o /tmp/hugo.tar.gz "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz" \
  && tar -xzf /tmp/hugo.tar.gz -C /tmp \
  && mv /tmp/hugo /usr/local/bin/hugo \
  && chmod +x /usr/local/bin/hugo \
  && rm -rf /tmp/*

WORKDIR /src
COPY . .

RUN if [ ! -f config/${ENV}.toml ]; then \
        echo "ERROR: config/${ENV}.toml not found"; \
        exit 1; \
    fi

RUN hugo --config config/${ENV}.toml --minify

This stage may contain tools. Here I install Hugo, copy the website sources and generate the public/ directory.

The second stage is already the runtime:

FROM nginx:alpine

COPY --from=builder /src/public/ /usr/share/nginx/html/

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

At this stage, I no longer care about Hugo. I only copy the build result, which is the public/ directory.

This is the core idea behind a multi-stage build:

COPY --from=builder /src/public/ /usr/share/nginx/html/

The final image does not inherit the whole workshop. It only gets what I explicitly copy from the previous stage.

What went into runtime?

In the final container, I do not need:

- Hugo
- Markdown files
- the .git directory
- build configuration
- curl
- tar
- tools for installing packages

I only need:

- nginx
- generated files from the public/ directory

And this is a very pleasant shift in thinking. Instead of treating a container like a small server, I treat it as a minimal package for one specific service.

What about debugging?

Of course, there is another side to this.

The more minimal the runtime, the less convenient debugging becomes. With a classic image, it is often possible to enter the container:

docker exec -it container sh

or:

docker exec -it container bash

With more minimal images, it may turn out that there is no bash, no sh, no curl and no package manager. At first, this can hurt a little.

But on the other hand — should a production container really be a place where I manually fix things?

I am increasingly leaning toward the answer: no.

If I need debugging, I can have a separate debug image, a separate builder stage, or test locally on a less restrictive image. But the final runtime should be as simple and predictable as possible.

Why did this catch my attention now?

The topic came back to me because of Red Hat Hardened Images. These are minimal container images designed to reduce the attack surface and include fewer unnecessary components.

For a static Hugo website, this idea fits almost perfectly.

Hugo builds the site, and the final container only serves it. It naturally asks for a split like this:

builder  → Hugo and website sources
runtime  → minimal nginx and the generated public/ directory

But before doing that, it was worth sorting out the build vs runtime distinction in my head. Without that, it is easy to treat a container like a small virtual machine where everything is included “just in case”.

Summary

The most important lesson from this exercise is simple:

Build is the preparation stage.
Runtime is the execution stage.

In the case of Hugo, the build stage needs the site generator, sources, configuration and tools. Runtime only needs an HTTP server and the generated files.

This approach brings order, a smaller image, a smaller attack surface and more repeatable deployments.

Most importantly, it changes the way I think about containers.

A container does not have to be a small server with the whole workshop inside. A container should run a specific service and not complain.

In the next post, I describe a concrete migration of Hugo from nginx:alpine to Red Hat Hardened nginx. Along the way, I ran into a classic issue with port 8080, Traefik and Bad Gateway.

If a similar deployment cleanup is needed for an application on a VPS, I described the scope on the Docker, Reverse Proxy and Application Deployments service page.

No slides. Just practice.