DEV Community

Luger Lex Pit-og
Luger Lex Pit-og

Posted on

[Quick Notes] "Docker-fying" my Portfolio Website

Notes to my future self, in plain English.


What I Was Trying To Do

I already had my portfolio website working on my computer using Hugo and a template for the design. I wanted to package it up in Docker so I could run it the same way anywhere, instead of relying on my machine's setup.


Lesson 1: Hugo Doesn't Actually "Run" a Website

  • When I ran hugo server on my computer, it felt like a website was "running." But that command is really just a preview tool for editing. It's not meant to be used for real, live traffic.
  • The command hugo --minify does something different: it takes all my content and the template and converts it into plain files — HTML, CSS, images — and dumps them in a folder called public/. Then it stops. Nothing is left running. It's like exporting a Word doc to PDF — a one-time conversion, not an app that stays open.

So a real website needs two separate things:

  1. Something to build the files (Hugo).
  2. Something to serve those files to visitors (a web server — I used nginx).

That's why my Docker setup has two stages.


Lesson 2: The Two-Stage Dockerfile

FROM hugomods/hugo:exts AS build
WORKDIR /src
COPY . .
RUN hugo --minify

FROM nginx:alpine
COPY --from=build /src/public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Think of it as two mini-containers glued together:

  • First part: a temporary workspace with Hugo installed. It builds my site into plain files.
  • Second part: a clean, lightweight container with just nginx (the web server). It grabs the finished files from the first part and throws away everything else — no Hugo, no build tools, just the website files and something to hand them out.

Lesson 3: My Template Needed an Extra Build Step

My first build failed. After digging in, the real problem was simple: my Hugo template also needed some JavaScript/CSS tooling (npm) to build properly, and I wasn't running that step. Without it, the template's styling code came up empty and broke the build.

Fix: install Node.js in the build stage and run the extra setup commands before building the site:

FROM hugomods/hugo:exts AS build
WORKDIR /src
RUN apk add --no-cache nodejs npm
COPY . .
RUN hugo mod npm pack && npm install
RUN hugo --minify

FROM nginx:alpine
COPY --from=build /src/public /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Takeaway: if a template comes with its own JavaScript/CSS setup, I need to run that setup too — Hugo alone won't do it automatically.


Lesson 4: Ports Are Confusing At First

Two separate mistakes here:

Mistake A: I typed -p 80:8080, but Docker's format is -p my_computer_port:container_port. Nginx inside the container listens on port 80, so the second number always has to be 80 — no matter what port I want to use on my own machine.

Mistake B: Even after fixing that, port 80 on my actual computer was already being used by something else. Easiest fix: just pick a different, unused port on my side.

docker run -p 8888:80 portfolio
Enter fullscreen mode Exit fullscreen mode

Takeaway: the EXPOSE line in a Dockerfile is just a note for humans — it doesn't control anything. The real listening port comes from nginx's own settings.


Lesson 5: The /cv Page Redirect Bug

Visiting mysite.com/cv sent me somewhere broken.

Turns out this was expected behavior gone slightly wrong: Hugo saves that page as a folder (/cv/index.html), so nginx automatically redirects /cv to /cv/. That part's normal.

The bug was that nginx was guessing the wrong port when building that redirect, it assumed port 80 (the port inside the container) instead of 8888 (the port I was actually using on my computer). So it sent my browser to the wrong address.

Fix: tell nginx not to guess the full address, just use a relative path:

server {
    listen 80;
    server_name _;
    absolute_redirect off;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
Enter fullscreen mode Exit fullscreen mode

And load that file into the container:

COPY nginx.conf /etc/nginx/conf.d/default.conf
Enter fullscreen mode Exit fullscreen mode

The Final Setup

Dockerfile:

FROM hugomods/hugo:exts AS build
WORKDIR /src
RUN apk add --no-cache nodejs npm
COPY . .
RUN hugo mod npm pack && npm install
RUN hugo --minify

FROM nginx:alpine
COPY --from=build /src/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

nginx.conf:

server {
    listen 80;
    server_name _;
    absolute_redirect off;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
Enter fullscreen mode Exit fullscreen mode

.dockerignore:

public/
resources/
.git/
node_modules/
Enter fullscreen mode Exit fullscreen mode

To run it:

docker build -t portfolio .
docker run -p 8888:80 portfolio
Enter fullscreen mode Exit fullscreen mode

The Short Version, In Plain Words

  • Hugo builds a website into files. It doesn't keep a website "running" — that's nginx's job.
  • A Docker build can use one big toolbox to build something, then hand the finished result to a small, clean container to actually serve it.
  • Some templates need an extra JavaScript/CSS setup step — don't skip it.
  • EXPOSE in a Dockerfile is just a label. The real port comes from the app's own settings.
  • When mapping ports, the right-hand number always has to match what the app inside is really listening on.
  • Redirects inside a container can point to the wrong port if you don't tell the web server to keep them relative.

Top comments (0)