Migrating Hugo from nginx:alpine to Red Hat Hardened nginx
In the previous post, I described what the split between build and runtime in containers means to me. Hugo is a very good example of this: the site generator is only needed during the build stage, while the final container should contain only the generated files and an HTTP server.
Since I already had this split in place, I decided to go one step further and replace the runtime of my website from the classic nginx:alpine image to registry.access.redhat.com/hi/nginx:latest, which is Red Hat Hardened nginx.
It sounded simple. And it actually was simple, but along the way one classic detail appeared: port 8080.
Starting point
My website is a static site built with Hugo. The pipeline builds the container image and then runs it on a Raspberry Pi. The whole thing is exposed through Traefik.
The previous setup was simple:
Hugo builder → public/ → nginx:alpine → port 80
The final stage of my Dockerfile/Containerfile looked more or less like this:
FROM nginx:alpine
COPY --from=builder /src/public/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
This worked correctly. Nginx was listening on port 80, and in the deployment I had port mapping like this:
-p ${PORT:-8080}:80
So the host port depended on the environment, but inside the container traffic always went to port 80.
Changing the runtime
Red Hat Hardened nginx can serve static files from this directory:
/usr/share/nginx/html/
So for Hugo, it is enough to copy the generated public/ directory there.
I changed the final stage to:
FROM registry.access.redhat.com/hi/nginx:latest
COPY --from=builder /src/public/ /usr/share/nginx/html/
EXPOSE 8080
The important difference is that this image listens on port 8080, not on 80.
This means that changing only the FROM line is not enough. I also had to update all places that assumed nginx inside the container was running on port 80.
A more complete Containerfile example
In my case, the builder can still be based on Alpine. This is where I install Hugo and build the website. The runtime is based on Red Hat Hardened nginx.
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
FROM registry.access.redhat.com/hi/nginx:latest
COPY --from=builder /src/public/ /usr/share/nginx/html/
EXPOSE 8080
One thing is worth noting: in the final image, I no longer set the nginx startup command manually. I use what the base image provides.
I also do not install tzdata, I do not use apk, and I do not try to add anything to the runtime. This container is supposed to serve static files and nothing more.
Deployment fix
In GitLab CI, I had this fragment responsible for starting the container:
deploy:
stage: deploy-frontend
needs: ["build", "build-backend"]
script:
- docker rm -f logos-hugo-$CI_COMMIT_REF_SLUG || true
- docker run -d --restart unless-stopped --network hugo-net --name logos-hugo-$CI_COMMIT_REF_SLUG -p ${PORT:-8080}:80 ${PROJECT_NAME}:${CI_COMMIT_REF_SLUG}
- docker network connect traefik "logos-hugo-$CI_COMMIT_REF_SLUG" || true
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
variables:
PORT: "1313"
- if: '$CI_COMMIT_BRANCH == "itg"'
variables:
PORT: "8080"
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
PORT: "80"
After changing the runtime to Red Hat Hardened nginx, I had to change the port mapping from:
-p ${PORT:-8080}:80
to:
-p ${PORT:-8080}:8080
So the corrected version looks like this:
deploy:
stage: deploy-frontend
needs: ["build", "build-backend"]
script:
- docker rm -f logos-hugo-$CI_COMMIT_REF_SLUG || true
- docker run -d --restart unless-stopped --network hugo-net --name logos-hugo-$CI_COMMIT_REF_SLUG -p ${PORT:-8080}:8080 ${PROJECT_NAME}:${CI_COMMIT_REF_SLUG}
- docker network connect traefik "logos-hugo-$CI_COMMIT_REF_SLUG" || true
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
variables:
PORT: "1313"
- if: '$CI_COMMIT_BRANCH == "itg"'
variables:
PORT: "8080"
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
PORT: "80"
In practice, this gives:
dev → host 1313 → container 8080
itg → host 8080 → container 8080
main → host 80 → container 8080
Bad Gateway in Traefik
After the change, the container was running, but the ITG environment returned Bad Gateway.
This was a very good moment to appreciate having an integration environment. Production stayed untouched, and the problem appeared exactly where it should.
A quick check of the Traefik configuration showed that the dynamic config still had port 80 hardcoded:
url: "http://logos-hugo-itg:80"
Since Traefik talks to the container over the Docker network, it does not care about host port mappings. It has to reach the actual port the service listens on inside the container.
After changing the runtime, it should be:
url: "http://logos-hugo-itg:8080"
And similarly for production:
url: "http://logos-hugo-main:8080"
This distinction is important:
-p 1313:8080
applies to traffic from the host to the container.
But Traefik uses the container address and its internal service port:
http://logos-hugo-itg:8080
After fixing the port in the Traefik configuration and restarting the proxy, ITG started working correctly.
Tests after migration
After the changes, I checked the basics:
docker ps
The containers showed the new port layout:
logos-hugo-dev 8080/tcp, 0.0.0.0:1313->8080/tcp
I also performed a standard HTTP test:
curl -I http://localhost:1313/
and tested the ITG environment through Traefik.
I also checked the contact form and the website layout on a smartphone. The form was working, and the mobile version looked the same as before.
This is important, because with changes like this it is easy to focus only on containers and ports, and forget that the whole website has to work, not just nginx itself.
What actually changed?
Before the migration:
builder:
- Alpine
- Hugo
- curl
- tar
- website sources
runtime:
- nginx:alpine
- port 80
- generated website files
After the migration:
builder:
- Alpine
- Hugo
- curl
- tar
- website sources
runtime:
- Red Hat Hardened nginx
- port 8080
- generated website files
The most important change is in the runtime. The final container is more minimal and closer to the rule that it should contain only what is needed to run the service.
Hugo still stays in the builder. Only the public/ directory goes into runtime.
Conclusions
This migration was small, but very educational.
First, the split between build and runtime really makes sense. Hugo is a tool for building the website, not something required to run it.
Second, changing the base image can have consequences in several places at once. In this case, the most important change was port 8080.
Third, it is important to remember that host port mapping and the service port seen by Traefik inside the container network are two different things.
Fourth, it is worth having an ITG environment. This is where Bad Gateway appeared, while production could calmly wait.
The whole change came down to a few practical steps:
1. Change the final image to registry.access.redhat.com/hi/nginx:latest
2. Copy /src/public/ to /usr/share/nginx/html/
3. Change the container port from 80 to 8080
4. Fix docker run in GitLab CI
5. Fix the service URL in Traefik
6. Test on ITG before production
The final result: my static Hugo website runs on Red Hat Hardened nginx, the contact form still works, the mobile layout looks correct, and I got another practical lesson about containers.
No slides. Just practice.
