First Time with Hugo… or second?
Table of Contents
A personal blog is something that I’ve long wanted to start, but also something I’ve tried, in fact, already thrice (at least). Unfortunately all the ways I’ve found to manage such blogs didn’t satisfy me enough, and my lack of perseverance made the rest.
But with some fresh ideas, I’ve managed to gather some courage and give Hugo a spin, giving myself another chance to throw something into the world.
In the last times I wanted to have a simple way to be able to manage such blogs only by throwing in some markdown notes, and getting in output pages of a decently formatted blog, nothing more. I think Hugo is doing this quite fine, but I’ve had to understand how to avoid things like Git, GutHub or a CI/CD pipeline — not that I don’t know anything about any of these things, but I wanted a leaner way to do this.
In this post I’ll cover the technicalities of how I’m personally working with Hugo, something that frankly speeking I do not advise to follow or copy. I wanted a simple way — my simple way — and this will most probably need some refinement. I’ll update this post if I need to do any major change to how I’m currently working with Hugo right now, but any comment, criticism or suggestion is warmly welcomed at blog (at) droliver (dot) it.
At of 27th September 2025, this setup is working fine with Hugo version
0.149.1
First installation#
I’ve tried with the containerized version of Hugo, but they’re not easy to use. As far as I’ve understood they are mantained by other people and not official. I therefore decided to go with a simple VM (or better, in my case a LCX container with Debian) with all the necessary tools installed directly by myself.
-
Prepare a blank VM with SSH access. I usually go with a Debian as I’m more confortable with, but if all the following tools are present on another distro, please go for it.
-
Following the official docs, install Hugo from the latest packages downloaded from main website.
-
I ended up not using the Debian 12 packages listed via
aptas they’re quite old, and some things might not work as currently expected. I’ve tried them and then went back on my steps due to the theme breaking in some points and the documentation not lining up in others. -
I suggest to choose the
extendedversion, it contains more libraries which might be needed by some themes -
Download the
.debpackage for your architecture (amd64for my VM) withwget, then install it withsudo dpkg -i ./hugo_extended_0.xxx.deb -
In this way the upgrades are straightforward: it’s sufficient to
wgetthe new version and install it onto the previous one with adpkg -i
-
-
I’ve decided to keep my website’s source in
/usr/src/hugo. After creating this folder, launch the following to create the website. Then I link the newly created folder from my user’s home, to have an easy way to reach it.mkdir /usr/src/hugo cd /usr/src/hugo hugo new site myhugowebsite cd ln -s /usr/src/hugo/myhugowebsite src-myhugowebsite -
Then goes the theme installation:
-
I’ve started from the theme Terminal, by remaining in the website root folder and downloading it with
git clone https://github.com/panr/hugo-theme-terminal.git themes/terminal -
Then it’s necessary to copy what reported in the GitHub page of the terminal theme, into the
hugo.tomlconfiguration file-
As my preferences, I’ve set the following in the same file
# show selector to switch language showLanguageSelector = true # center theme with default width centerTheme = true # whether to show a table of contents # can be overridden in a page's front-matter Toc = true
-
-
-
To expose on the local network a preview version of the website, launch the following command, then load into a web browser
http://[VM_IP]:1313.hugo server --bind [VM_IP] --baseURL=http://[VM_IP]:1313Now we have a website up and running!
A multilanguage blog and your first post#
-
To set up a second language, add in the
hugo.tomlfile new lines about the second language you want to write in[languages] ... [language.it] languageName = "ITA" title = "Il mio sito web" -
In every case, to have a template to start from when creating new posts, copy the archetype from the
terminaltheme to the main folder of archetypes. These are used as templates by the Hugo binary to create a post in the next step, or other resourcescp theme/terminal/archetypes/posts.ms archetypes/ -
Start creating the first post with
hugo new content/posts/my-first-post.en.md-
One way to have multilingual content is to put the language code at the end of the content name (for me,
.en.mdand.it.md). Content with the same name will be linked together -
If there is no language, Hugo will take the default one as per
hugo.conf -
The archetype used will be based on the folder we’ll put content into.
postsfolder will apply the correspondingposts.msarchetype
-
-
OPTIONAL: If you want to have a link that from a post in one language bring to the other(s), you can proceed in this way, loosely following this guide
-
Add to the following piece of code in the HTML template for the single post in your theme (in my case,
themes/terminal/layouts/_default/single.html){{ if .IsTranslated }} <h4>{{ $.Site.Params.translations }}</h4> <ul> {{ range .Translations }} <li> <a href="{{ .RelPermalink }}">{{ .Language.Lang }}: {{ .LinkTitle }}{{ if .IsPage }} {{ end }}</a> </li> {{ end }} </ul> {{ end }} -
I edited the title of this block converting it into a site param,
$.Site.Params.translations, which can be easily translated inside thehugo.configfile. The original used thei18nfunction which I didn’t care to consider, at least for the moment, but that should be in fact used instead of what the theme’s creator has hacked together. Maybe one day he’ll fix it1, we’ll see.- I’ve also removed the word counter as I wasn’t able to make it work
-
Edit CSS styles#
Paint me like one of your websites – My blog, maybe?
If we want to be serious about style, we need to step up the CSS game 😎. I’ve taken inspiration from this guide which will come back for another important step.
- Firstly, we need to prepare the blog to receive and apply correctly custom CSS. For this, in this case my theme helped by letting me find a
extended_head.htmlin the folderthemes/terminal/layout/partialwith the following content
extended_head.html
<!--
To add an extended head section, please create
`layouts/partials/extended_head.html` in your Hugo directory.
-->Which I happily copied in the same path indicated by the comment.
- In case we need to import into every page of our blog an HTML snippet, we can symply do so in the following way
{{ partial "snippet.html" . }}
- If we need to import CSS files, we need instead another step. Given a
style.cssfile created under the folderstatic/css, in thehugo.tomlcreate a new parameter in the following way:
hugo.toml
...
[params]
...
# Custom CSS files, which are injected in layouts/partials/extended_head.html
custom_css = ["css/style.css", "css/style2.css"]While in the same extended_head.html insert the followin snippet, which will read all the files defined into the custom_css variable and load them into the page:
hugo.toml
<!--
Please add/edit the following snip into the hugo.toml file
custom_css = ["css/custom.css"]
-->
{{ range .Site.Params.custom_css -}}
<link rel="stylesheet" href="{{ . | absURL }}">
{{- end }}Multimedia embedding#
Adding images in a multilanguage post#
Even with contrasting opinions on the great big Internet, I’ve understood2 that a preferable way to manage images within Hugo is with Page Bundles.
Basically, if we need to add for example an image to my-post2 which is part of this structure
content/
├── posts/
│ ├── my-post1.en.md
│ ├── my-post1.it.md
│ ├── my-post2.en.md
│ ├── my-post2.it.md
│ ├── ...
├── ...
We can convert my-post2 into a “Page Bundle” in the following way, such that the post now lives in the _index.md file(s)
content/
├── posts/
│ ├── my-post1.en.md
│ ├── my-post1.it.md
│ ├── my-post2/
│ │ ├── index.en.md
│ │ ├── index.it.md
│ │ ├── image1.jpg
│ │ └── image2.jpg
│ ├── ...
├── ...
And refer to the images in the bundle simply with markdown . In alternative, for the Terminal theme I’m using, it’s available a specific shortcode figure which has the following form (which differs from the one reported on the README, I believe because in the meanwhile they have updated it):
{ { < figure src="image1.jpg" position="center" caption="This is an image" width=650 alt="The image is an image" loading="lazy" >}}
Or, if like me you want for this theme to have the nice caption written inside the coloured border, you can use the following shortcode recovered via Hugo docs on the corresponding file on GitHub, which I’ve then edited to consider the presence of a position option and put the image in the center:
<figure {{ with .Get "class" }} class="{{ . }}"{{ end }} {{ with .Get "position" }} style='margin-left:auto;margin-right:auto;'{{ end }} >
{{- if .Get "link" -}}
<a href="{{ .Get "link" }}"{{ with .Get "target" }} target="{{ . }}"{{ end }}{{ with .Get "rel" }} rel="{{ . }}"{{ end }}>
{{- end -}}
{{- $u := urls.Parse (.Get "src") -}}
{{- $src := $u.String -}}
{{- if not $u.IsAbs -}}
{{- with or (.Page.Resources.Get $u.Path) (resources.Get $u.Path) -}}
{{- $src = .RelPermalink -}}
{{- end -}}
{{- end -}}
<img src="{{ $src }}" {{ with .Get "position" }} style='margin-left:auto;margin-right:auto;'{{ end }}
{{- if or (.Get "alt") (.Get "caption") }}
alt="{{ with .Get "alt" }}{{ . }}{{ else }}{{ .Get "caption" | markdownify| plainify }}{{ end }}"
{{- end -}}
{{- with .Get "width" }} width="{{ . }}"{{ end -}}
{{- with .Get "height" }} height="{{ . }}"{{ end -}}
{{- with .Get "loading" }} loading="{{ . }}"{{ end -}}
><!-- Closing img tag -->
{{- if .Get "link" }}</a>{{ end -}}
{{- if or (or (.Get "title") (.Get "caption")) (.Get "attr") -}}
<figcaption>
{{ with (.Get "title") -}}
<h4>{{ . }}</h4>
{{- end -}}
{{- if or (.Get "caption") (.Get "attr") -}}<p>
{{- .Get "caption" | markdownify -}}
{{- with .Get "attrlink" }}
<a href="{{ . }}">
{{- end -}}
{{- .Get "attr" | markdownify -}}
{{- if .Get "attrlink" }}</a>{{ end }}</p>
{{- end }}
</figcaption>
{{- end }}
</figure>
This is a shortcode, more on that in the following paragraph.
Videos and shortcodes#
At some point I wanted to add a moving image to one of my posts (wow, such progress) but I couldn’t find any way with the ways I had available. Digging into the issue I found that this is not something out-of-the-box and that I had to write it myself. Nice!
This is done by creating a custom shortcode, which is basically a snippet of HTML code parametrized accordingly to the Hugo standard, and saved into the folder /layout/_shortcodes. These snippets can be later called back with the pattern { { < shortcode_name param=value param2=value2 >}}
Based on a basic shortcode I’ve found on the Internet, I’ve modified to create a gif and a video shortcode, to have both available when needed: the first show a continuous loop of a video (I’ve tested at the moment with a .webm file) and the second one is more suitable for an actual video, with controls and no loops.
gif.en.html
{{ $imgSrc := .Get "src" }}
<video class="gif-shortcode" preload="{{ .Get "preload" }}" autoplay loop muted>
<source {{ with .Page.Resources.Get $imgSrc }}src="{{ .RelPermalink }}"{{ end }} type="{{ .Get "type" }}">
There should have been a GIF here but your browser does not seem to support it.
</video>
<style>
.gif-shortcode {
display: block;
margin: 25px 0;
margin-left: auto;
margin-right: auto;
max-width: 100%;
border: 8px solid var(--accent);
border-radius: var(--radius);
padding: 8px;
overflow: hidden;
}
</style>video.en.html
<video class="video-shortcode" preload="{{ .Get "preload" }}" controls>
<source {{ with .Page.Resources.Get $imgSrc }}src="{{ .RelPermalink }}"{{ end }} type="{{ .Get "type" }}">
There should have been a video here but your browser does not seem
to support it.
</video>
<style>
.video-shortcode {
max-width: 100%;
height: auto;
}
</style>The parts with the .Page.Resources.Get and the .RelPermalink are present because otherwise Hugo is unable to correctly resolve links for the multilingual posts. In this way it’s possible instead to refer to the images as showed before, both if there is a language ID (e.g. image1.en.jpg) or not (e.g. image.jpg).
Hosting specialty: my dev and prod setup#
I initially thought about exposing directly the VM I was building, maybe by going through a Nginx installation to expose the public folder as I already know it enough to bring it up and running in a matter of half an hour – something I’m not as well confident in saying about Traefik which posed a few difficulties also in a standalone installation, apart from the ones I already found using it as a container.
Despite somebody still does it anyway, and there has been much debate about it3, in fact the hugo server exposition is not intended to be exposed externally for production reasons, so I tried to stay away from that option.
Anyway, as I wanted to follow the KISS principle for this project, I scratched the idea of direct exposure and opted for an easier and moreover alredy tested idea: my Fastmail account, which includes the ability to upload files and turn them into a website with built-in TLS certificate.
I only had to find a way to make the process of generating my website as automatic as possible, therfore I followed these steps
-
Firstly, I wanted to keep the dev part of the blog always up and running via a daemon, which I defined as follows with a
sudo systemctl edit --force --full hugodev.service[Unit] Description= Hugo Dev Server [Service] WorkingDirectory= /usr/src/hugo/MYSITE/ ExecStart= /usr/local/bin/hugo server --watch --baseURL=MYSITE.it --bind=0.0.0.0 --buildDraft --buildFuture --minify --renderToMemory Restart= always RuntimeMaxSec= 6h [Install] WantedBy= multi-user.target- Watch out with the bind to
0.0.0.0, this means that every request coming from every IP can be honoured by the server. I know this server is only exposed in its local LAN but please double check if it’s the case for you too. - The
--buildDraftbuilds draft articles too, so that it’s possibile to look at them while writing them and befor publication. - This services restarts every six hours so that some problems due to caching can be avoided.
- I’ve noticed that sometimes the dev version of the website seems to take stuff from the prod build: the URLs contain the public domain and not the
baseURLI’ve set, the draft articles are not visibile, and some other issues. I’ve upgraded the hugo binary and still got these issues, so I’m testing the--renderToMemoryflag to check if the issue can be avoided in this way
- Watch out with the bind to
-
Then I prepared to go live via Fastmail
-
First thing I installed rclone, to be able to copy the content of the public folder to the Fastmail drive via WebDAV
sudo apt update && sudo apt install unzip sudo -v ; curl https://rclone.org/install.sh | sudo bash- Always watch out what you pipe in your bash!
-
I then configured
rclonefor connecting to Fastmail. I created the setting for therootuser, as I didn’t have the occasion to create a specific user for this, might do it latersudo rclone config # Follow the setup- For the credentials, create an App password under Fastmail Settings > Privacy and Security > Connected apps > App passwords
-
I checked if the config is working as expected with a
rclone lsd myblog_remote:which should list the top-level folders -
Let’s proceed with
rclone copy /path/to/hugo/site/public myblog_remote:myblog_folderto check ifrcloneis able to copy the content of thepublicfolder. You might have to check here eventually permissions on yourpublicfolder.
-
-
Now we can set up the service to make this more automatic
-
Create a new script with the following content, I put it under
/usr/bin/hugoupload.sh#!/bin/bash # This script rebuilds the blog at the following path, and upload it to the destination via WebDAV with rclone. rclone config is set up for root user. cd /usr/src/hugo/MYSITE # Force re-build of the blog. This will change a lot of edit dates on the files, even if not necessary... hugo # ..for this reason we use only checksum to check for need to re-upload files, with --checksum switch rclone copy --checksum /usr/src/hugo/MYSITE/public fastmail_blog:remote_myblog- Remember to
sudo chmod u+x hugoupload.shthe script. Don’t tell how I know… - The user who runs the service just need to have permission to run hugo and to read the
publicfolder
- Remember to
-
Create the following service with
systemctl edit --force --full hugoupload.service[Unit] Description= Hugo Prod Build and Upload to Fastmail [Service] ExecStart= /usr/bin/hugoupload.sh Type=oneshot [Install] WantedBy= multi-user.target- This sevice can’t be set up with
Restart=alwaysas the serice will stop as soon as the copy is completed, and it would therefore stuck in a loop when it’s furiosly uploading the same files over and over. We use insteadType=oneshotand define a properTimer - Test the service by launching it with
systemctl start hugoupload.serviceand then by checking if there any error or issue withjournalctl -u hugoupload.service
- This sevice can’t be set up with
-
Set a timer to decide when we actually want the service to start, with
systemctl edit --force --full hugoupload.service. I decided to trigger this after three minutes from when the system starts, and then every three hours:[Unit] Description=Hugo Prod Build and Upload to Fastmail Requires=hugoupload.service [Timer] OnStartupSec=3min OnUnitActiveSec=3h Unit=hugouploadservice [Install] WantedBy=timers.target- When saved, check that the timer is correctly set up with
systemctl list-timers
- When saved, check that the timer is correctly set up with
-
Refresh the services’ battery with
sudo systemctl daemon-reloadand remember toenableandstartthehugoupload.timer- The service is fine as
disabledas it’s the timer that triggers the service to act
- The service is fine as
-
The blog is now live! Check on the server if any additional settings are required, personally I didn’t need to do anything else luckily, SSL is already fully managed and I also had prepared a DNS record.
- Remember to update the
hugo.tomlsettings with the new domain, otherwise you might have issues on the website
- Remember to update the
-