Skip to content
← All articles
Article

Create a portfolio with Hugo (week 1)

Listen to the audio story

Week one of a Hugo portfolio · 1:53 · AI-written script, synthetic voices

Transcript

Ava: Four toolchains, and only one of them deployed automatically. That's what Antoine's old site repository looked like right before he moved to Hugo in January 2024.

Andrew: Four? For a personal site?

Ava: Yes. A web résumé built with npm, a PDF CV in LaTeX, a links page in Hugo, and a blog living in a git submodule. Only the web résumé had a GitHub Actions workflow. The last commit, a week before the post, deleted the old site folder.

Andrew: So week one was a clean slate.

Ava: Right. He picked Hugo because it's static, so hosting is cheap and there's no database to patch. And posts are Markdown in git, so the repo is the source of truth. A WordPress-style admin, he says, he'd have ignored by week two.

Andrew: What did he learn from that first links page?

Ava: A few good lessons. He'd copied the Lynx theme into the site instead of using it as a module, so demo scaffolding came along that pointed at a folder that didn't exist.

Andrew: Ouch. Anything sneakier?

Ava: The Docker setup ran Hugo's development server, not a static build. And the Compose file mounted the project in one folder while the image had copied the site into another, so edits on his machine never reached the running container.

Andrew: The classic. What about the theme he actually chose?

Ava: HBTheme. It came with comments and Hugo modules, so no layouts from zero. The tradeoff is an opinionated structure, which gets awkward when you want custom content types.

Andrew: And Substack?

Ava: It runs in parallel for longer essays and email delivery. Hugo stays the canonical professional home.

Andrew: So what would he do differently?

Ava: Pull themes as modules, build static HTML in CI for every piece, and keep translation files complete. The full write-up has the details, plus the iframe shortcode.

Site preview after first deploy — HBTheme defaults with minimal overrides
Table of contents

Week one of moving my site to Hugo (January 2024): pick a theme, ship something fast, and prove the repo could carry more than a PDF résumé. This post is the log — where I started from, HBTheme, a data story embed, Substack in parallel, and a reusable iframe shortcode.

1. Why Hugo and not a CMS

ChoiceReason
Static generationCheap hosting, fast builds, no database to patch
Markdown sourcePosts and projects live in git — reviewable diffs
ModulesPull theme + partials without vendoring everything

As an engineer I wanted the repo as source of truth: Markdown in, HTML out, deploy to GitHub Pages or similar. WordPress-style admin was overhead I would ignore after week two.

2. Where I started from

Before week one, the repository behind my old site was a set of separate pieces, each with its own toolchain:

PieceHow it was builtHow it shipped
Web résuméresume.json (JSON Resume) exported with resume-cli and the elegant theme, plus a dark variant with kendallThe only GitHub Actions workflow: on every push, npm ci, npm run build, copy index.html to dist/, publish with JamesIves/github-pages-deploy-action. A Dockerfile could also serve it from nginx:alpine
PDF CVLaTeX on the Russell classPDF committed next to the source, no CI build
PapersTwo research PDFs in a folderStatic files
Links pageHugo, built from the Lynx theme with Tailwind CSSA Dockerfile running hugo server, no CI build
BlogA git submodule pointing to a third-party Hugo theme (blist)The blog content itself was no longer in the repository

The last commit, a week before this post, deleted the old site folder. Week one started from that cleared ground: four toolchains (npm, LaTeX, Hugo, Docker) and only one of them deployed automatically.

The links page was my first real Hugo site, and it explains a lot of the choices I made later.

Theme setup. I had copied the Lynx theme into the site instead of using it as a theme: theme.toml, layouts/ and assets/ sat at the root. The theme’s own scaffolding came along too. A netlify.toml and a Lighthouse CI config both pointed at an exampleSite folder that did not exist in my copy. That is the main reason the table above says Modules: I wanted the next theme pulled in, not pasted in.

Config. Everything visible came from config.toml. enableEmoji = true, disableKinds = ["taxonomy", "term"] (no tag pages for a one-page site), and a [module.hugoVersion] block with extended = false and min = "0.86.1". The links are a list of one-key tables, and the key does double duty:

[author]
  name = "Antoine Boucher"
  image = "profile.jpg"   # in static/
  links = [
    { github = "https://github.com/<user>" },
    { linkedin = "https://linkedin.com/in/<user>" },
    { email = "mailto:<user>@example.com" },
  ]

The home template loops over that list. The key (github) picks the icon assets/icons/github.svg and the label link.github from the i18n files. A value can also be a table with href, icon, text, target or title to override the defaults. The avatar is resized to 288×288 when Hugo can find it as a resource.

CSS without Hugo extended. Tailwind runs from an npm script and writes assets/css/compiled/main.css, which is committed. Hugo only has to bundle files, so the standard (non-extended) binary is enough. The head partial chains the steps:

{{ $bundleCSS := $assets.Get "css" | resources.Concat "css/main.bundle.css" | resources.Minify | resources.Fingerprint "sha512" }}

The fingerprint also gives an integrity attribute on the <link> tag. An optional assets/css/custom.css is appended to the bundle when it exists.

Content structure and i18n. No posts, no sections: one home page, a single.html layout with a back link, and two translation files (en.yaml, fr.yaml) for link labels, the “Home” link and the footer (“Powered by” / “Propulsé par”). That was my first bilingual Hugo file, long before bilingual posts.

Shortcodes. Two small ones. {{< icon "github" >}} inlines an SVG from assets/icons/. {{< lead >}}…{{< /lead >}} wraps an intro paragraph in larger, muted text. The icon shortcode falls back to the i18n key shortcode.icon_none when the SVG is missing, but neither translation file defines that key, so a typo in an icon name rendered as an empty <em>. Small, but it taught me to grep i18n keys before trusting a theme’s fallback.

Deployment. The Dockerfile used klakegg/hugo:0.101.0 and ran server, which is Hugo’s development server, not a static build. The Compose file mounted the project at /app while the image had copied the site into a different working directory, so edits on the host never reached the running container. Static HTML from hugo --minify, built in CI like the résumé, would have been simpler and faster.

4. HBTheme in week one

HBTheme gave comments, npm-friendly assets, and Hugo modules so I did not rebuild layouts from zero.

Tradeoffs I noticed immediately:

  • Opinionated structure — fast start, later fights when you want custom post types
  • Asset pipeline — npm steps add CI time but keep SCSS/JS sane
  • Docs depth — theme docs matter more than Hugo docs for week-one velocity

I would still pick a batteries-included theme for a personal site; I would budget time to peel modules once the site outgrows the defaults.

5. Data on the page (not only posts)

Early experiment: embed a Montreal car-theft analysis (friend’s Substack project) inside Hugo pages — proof the stack could carry charts and longform, not only short posts like this one.

Étude des vols de voitures à Montréal[1]

Lesson: iframe or link-out is fine for guest content you do not want to mirror; native Hugo charts would come later with page bundles and static assets.

6. Substack in parallel

Substack for longer essays and a newsletter-shaped audience. Hugo for the portfolio shell and project index; Substack when the piece needs comments/email delivery without me running mail servers.

That split still makes sense: Hugo = canonical professional home; Substack = optional longform channel.

7. Iframe shortcode

Posts can embed HTTPS pages with the site iframe shortcode (src required; optional title, height, loading, etc.):

{{< iframe src="https://example.com/" title="Example" height="480" >}}

Example — old FlashGames demo still hosted on GitHub Pages:

Use iframes sparingly — accessibility, mobile height, and third-party cookie rules break more often than Hugo builds fail.

Rationale in one line: legacy demos (Flash-era games) still live on GitHub Pages; Hugo wraps them with a stable URL and my typography instead of maintaining two full front-ends.

8. What I would do differently

Looking back at that repository with what the site became:

  • Pull themes as modules. A copied theme brings its demo scaffolding and makes upstream fixes a manual merge.
  • Build static HTML in CI for every piece. One workflow that runs hugo --minify and the résumé export, instead of a dev server in a container for one part and a workflow for another.
  • Keep translation files complete. Every i18n key a template calls should exist in each language, including fallbacks.
  • Commit generated CSS only when it buys something. Here it did: it let plain Hugo build without Node. With Hugo Pipes and a Node step in CI, it becomes noise in diffs.

9. What changed since week one

The current site grew modules, bilingual posts, conference notes, and MCP-era tooling posts. Full log: Website refonte with Hugo (part 2). Also professional résumé + JSON Resume and the blog index.

References

  1. Étude des vols de voitures à Montréal. (). [Web page] mohamedilias.substack.com.

References use this article’s publication date.