Developer Tools Azure

Hugo on Ubuntu 24.04 on Azure User Guide

| Product: Hugo on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and use of Hugo on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Hugo is the leading open source static site generator: it takes Markdown content and HTML templates and renders them into a complete static website, extremely fast, from a single Go binary.

A static site has no database, no application runtime and no request time rendering, so there is nothing to keep patched behind the web server and nothing to fall over under load. The trade off is that something has to build the site and something has to serve it — and that is exactly what this image is.

This is a build and serve appliance, not a bare command line tool. Hugo on its own is a CLI. This image is the whole loop: Hugo installed system wide, a complete working starter site on disk, nginx serving the generated output on ports 80 and 443, and a documented publish path that turns an edit into a live page. Point a browser at the VM's public address the moment it finishes booting and you get a real, styled website — not a directory listing, not an nginx welcome page and not an error.

The extended edition, and it matters. Hugo ships in two builds. The extended build is the one that embeds the SCSS/Sass transpiler, which a large fraction of Hugo themes require; on a plain build those themes fail with this feature is not available in your current Hugo version. This image ships extended, and the proof is functional rather than cosmetic: the starter site's own stylesheet is real SCSS compiled through Hugo Pipes, so the site could not render at all on a non extended binary. Every successful publish on this appliance is a working demonstration of the transpiler.

A starter site that is documentation, not filler. The pages the appliance serves on first boot are about the appliance: how to add a page, how to add a section, how to change the look, how publishing works and how to swap in a third party theme instead. The templates and stylesheet were written by cloudimg, so no third party theme is vendored — there is no upstream theme licence to carry and nothing that can go stale underneath you. Delete all of it and put your own site there; nothing in layouts/ is load bearing.

Publishing cannot take your site down. hugo-publish renders into a staging directory, checks the result is a real site, and only then swaps it into place. A syntax error in a template or in hugo.toml makes the command fail loudly with the error while the previously published site keeps serving. It also reads Hugo's own output rather than trusting its exit status, so a build that reports success while emitting errors is still treated as a failure.

No login, and that is deliberate. A static site generator is a build tool: content is edited over SSH by someone who already holds shell access, and the site it generates is public by definition. So this appliance ships no admin panel, no login and no upload form — which means there is no application credential to rotate and nothing authenticated exposed to the internet. The Hugo development server, which binds a live reload listener and watches the filesystem, is never installed as a service; nginx serves pre rendered bytes only.

A per instance TLS certificate, and no key in the image. The HTTPS listener needs a certificate. Rather than baking one in, which would put the same private key on every customer VM, the image ships no certificate and no key at all. Both are generated on your VM at first boot, and nginx is gated so it physically cannot serve before that has happened.

What is included:

  • The official Hugo 0.164.0 extended release binary, pinned by SHA-256 and redistributed unmodified, at /usr/local/bin/hugo

  • A complete working starter site at /var/www/hugo — content, templates and an SCSS stylesheet, all cloudimg authored

  • nginx serving the generated site on 80 and 443, with a per instance self signed certificate generated at first boot

  • hugo-publish — one command that re renders the site and swaps it in atomically

  • hugo-publish.path and hugo-publish.timer — systemd watchers that republish your edits with no command run

  • hugo-selftest — a shipped command that proves the binary is extended, that the site really re renders, that nginx is serving exactly those bytes, and that every asset the page references resolves

Prerequisites

  • Active Azure subscription, SSH public key, VNet + subnet in target region

  • Subscription to the Hugo listing on Azure Marketplace

  • Optionally, a domain name you can point at the VM (the appliance works on its raw public IP; a domain is a later step)

Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is a comfortable starting point — Hugo renders the bundled site in well under a second and nginx serving static files is close to free. Very large sites (tens of thousands of pages) render faster on more cores, so move to a compute optimised size such as Standard_F4s_v2 if your build time matters.

Step 1: Deploy from the Azure Portal

Search Hugo in Marketplace, select the cloudimg publisher, click Create. NSG rules: TCP 80 and TCP 443 from the networks that should reach the website (the whole internet, if the site is public — which is the normal case for a static site), and TCP 22 for admin SSH from your management network only.

Step 2: Deploy from the Azure CLI

az vm create \
  --resource-group my-hugo-rg \
  --name my-hugo-vm \
  --image cloudimg:hugo:default:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

# Open the website to the networks that should see it
az vm open-port --resource-group my-hugo-rg --name my-hugo-vm --port 80  --priority 1010
az vm open-port --resource-group my-hugo-rg --name my-hugo-vm --port 443 --priority 1020

Step 3: Connect and confirm the site is being served

Log in over SSH as azureuser. First boot resolves this VM's public address, writes it into the site configuration, generates the TLS certificate and renders the site — a few seconds after the VM reports ready.

sudo systemctl is-active nginx.service
hugo version

Expected output:

active
hugo v0.164.0-ce2470e7012b5ab5fc4e10ebe4027e9f8d9e00dc+extended linux/amd64 BuildDate=2026-07-06T16:39:30Z VendorInfo=gohugoio

The +extended suffix is the one to check. It is what tells you the embedded SCSS transpiler is present, and therefore that SCSS based themes will build.

Confirm nginx holds both website ports, and that the automatic publish watchers are running:

sudo ss -lntp | grep -E ':(80|443)\b'
systemctl is-active hugo-publish.timer hugo-publish.path

Hugo reporting version 0.164.0 with the +extended suffix, nginx and both hugo-publish watchers reporting active, and the listener table showing nginx holding ports 80 and 443

The image records exactly which upstream artifact it shipped, including the pinned digest:

cat /usr/local/share/hugo/VERSION

Step 4: Look at the site

Open http://<public-ip>/ in a browser. HTTPS on the same address works too, and will warn once about the self signed certificate — Step 8 replaces it with your own.

The generated Hugo site as served by the appliance: a styled home page with the site header, navigation, rendered Markdown prose, a syntax highlighted code block and a rendered Markdown table

The styling on that page is worth a second look, because it is evidence rather than decoration. The stylesheet does not exist as a .css file anywhere on disk — it is compiled from assets/scss/main.scss by Hugo's embedded transpiler every time the site is published. A page that renders styled is a page that was built by the extended edition.

The Posts link shows the section index. Every entry is one Markdown file in content/posts/.

The posts section index listing the starter site's three posts, each rendered as a card with its title, date and summary

And an individual post shows what Hugo does with Markdown — headings, prose, inline code and fenced code blocks all become real HTML.

A single post page rendered from Markdown, showing headings, body prose, inline code spans and syntax highlighted fenced code blocks

From the VM itself you can confirm that what nginx is serving really is Hugo's output:

curl -s http://127.0.0.1/ | grep -oE 'content="Hugo [0-9.]+"'

Expected output:

content="Hugo 0.164.0"

Step 5: Where the site lives

Everything is under /var/www/hugo, owned by an unprivileged hugo account.

ls -1 /var/www/hugo
find /var/www/hugo/content -name '*.md' | sort
Path What it is
content/ your Markdown — one file per page
layouts/ the HTML templates (baseof.html is the page shell)
assets/scss/main.scss the stylesheet source, compiled at publish time
static/ files copied to the site verbatim (images, downloads)
public/ the generated site — this is nginx's document root
hugo.toml site configuration, including baseURL

Note that the site source sits one level above nginx's document root, so your Markdown, templates and configuration are not reachable over HTTP.

The site directory listing showing content, layouts, assets, static and public, the six Markdown source files, and the generated output directory nginx serves

This VM's own endpoint summary is written at first boot. It is root only, and it spells out the paths you will actually use:

sudo cat /root/hugo-endpoints.txt

Step 6: Publish your first change

Add a page, publish, and it is live. That is the whole loop.

sudo tee /var/www/hugo/content/posts/my-first-post.md >/dev/null <<'EOF'
---
title: "My first post"
date: 2026-02-01
---

Written in Markdown, rendered by Hugo, served by nginx.
EOF
sudo hugo-publish

Expected output:

[...] hugo-publish: change detected: /var/www/hugo/content/posts
[...] hugo-publish: published 12 page(s), 104K on disk, into /var/www/hugo/public

Confirm the page is live:

curl -sf http://127.0.0.1/posts/my-first-post/ | grep -oF 'My first post'

Adding a Markdown page, running hugo-publish, and curling the new URL to show the rendered title and heading served back by nginx

In day to day use you will edit with an editor rather than a heredoc. sudo -e opens your $EDITOR on a copy and installs it when you save:

sudo -e /var/www/hugo/content/posts/my-first-post.md
sudo hugo-publish

Hugo can also scaffold the front matter for you:

cd /var/www/hugo && sudo -u hugo hugo new content posts/another-post.md

That writes the file with draft = true set. Delete that line, or publish drafts with hugo --buildDrafts, and the page goes live.

A broken edit will not take your site down. hugo-publish renders to a staging directory and only swaps it in once the render has produced a real site, so if you leave a template half finished the command fails with the error and the previous site keeps serving. Fix the file and publish again.

Step 7: Automatic publishing

You do not have to remember hugo-publish. Two systemd units watch for you, and both trigger the same hugo-publish.service:

  • hugo-publish.path reacts within seconds to a change directly under content/ or to hugo.toml
  • hugo-publish.timer sweeps every minute, which is what catches edits deeper in the tree — content/posts/, assets/scss/, layouts/

hugo-publish skips the render entirely when nothing has changed, so an idle VM does no work. Concurrent publishes are serialised with a lock, so a sweep firing while you are running the command by hand is harmless.

Try it — edit a file and run nothing at all:

sudo tee -a /var/www/hugo/content/posts/my-first-post.md >/dev/null <<'EOF'

This paragraph was added without running any publish command.
EOF
for i in $(seq 1 30); do
  page="$(curl -s http://127.0.0.1/posts/my-first-post/ || true)"
  case "${page}" in *"without running any publish command"*) break ;; esac
  sleep 5
done
curl -sf http://127.0.0.1/posts/my-first-post/ | grep -oF 'without running any publish command'

Watch the publishes as they happen:

sudo journalctl -u hugo-publish.service -n 20 --no-pager

If you would rather publish only on demand, disable the watchers — hugo-publish still works by hand:

sudo systemctl disable --now hugo-publish.path hugo-publish.timer

Step 8: Point a domain at it

Two things change when you move from the raw IP to a domain: the baseURL Hugo bakes into absolute links, and the TLS certificate nginx presents.

Ordinary navigation and asset links on this site are relative, so they already work over HTTP, over HTTPS and behind any hostname. baseURL is what Hugo uses for the canonical tag, the RSS feed and the sitemap, so it should be your real address:

sudo -e /var/www/hugo/hugo.toml
# set:  baseURL = "https://<your-domain>/"
sudo hugo-publish

Then install a certificate for that domain. The appliance reads its certificate from a fixed pair of paths, so replacing the files and reloading nginx is all that is needed:

# Replace with your own certificate and key for <your-domain>
sudo install -m 0644 -o root -g root fullchain.pem /etc/nginx/tls/cloudimg-hugo.crt
sudo install -m 0600 -o root -g root privkey.pem   /etc/nginx/tls/cloudimg-hugo.key
sudo nginx -t && sudo systemctl reload nginx

For an automatically renewed certificate, install certbot and point it at the same paths, or terminate TLS at an Azure Application Gateway or Front Door in front of the VM.

Step 9: Make it your own site

The starter content, templates and stylesheet are all yours to change or delete.

  • Content — remove everything in content/ and write your own. A directory under content/ becomes a section: content/notes/ serves at /notes/ and gets an index page from layouts/section.html.
  • Lookassets/scss/main.scss is real SCSS with variables, nesting and mixins. Change $navy or $accent at the top and publish; the transpiler recompiles it.
  • Templateslayouts/baseof.html is the page shell. home.html, page.html and section.html fill in its main block.

The appliance's own About page, describing what is installed, the deliberate absence of any login, and the Apache-2.0 licensing of Hugo itself

To use a third party theme instead of the bundled templates, clone it into themes/, name it in the configuration, and remove layouts/ so the theme's own templates take over. Because this image is the extended edition, SCSS based themes build correctly:

cd /var/www/hugo
sudo -u hugo git clone --depth 1 <your-theme-repo> themes/<your-theme>
sudo -e /var/www/hugo/hugo.toml     # add:  theme = "<your-theme>"
sudo rm -rf /var/www/hugo/layouts
sudo hugo-publish

Keeping your site in Git is the usual next step: hold content/ (and layouts/ if you have customised it) in a repository, pull it onto the VM, and let hugo-publish.path publish the result.

Step 10: Prove the whole chain

The appliance ships the same check the build gate runs. It verifies the binary is the pinned extended build, that the site really re renders right now, that the SCSS compiled, that nginx is serving byte for byte what was rendered, that HTTPS presents this VM's own certificate, that every asset the page references returns 200 over both HTTP and HTTPS, and that the published baseURL is a routable address.

sudo hugo-selftest

Expected output:

hugo-selftest — cloudimg Hugo appliance
  ok   hugo v0.164.0 extended: hugo v0.164.0-...+extended linux/amd64 ...
  ok   site re-renders and assets/scss/main.scss compiled to CSS (3410 bytes)
  ok   nginx serves the current render byte-for-byte, and it is Hugo-generated
  ok   HTTPS serves the same site with this VM's certificate
  ok   all 8 referenced local assets return 200 over HTTP and HTTPS
  ok   baseURL is routable: http://203.0.113.10/
hugo-selftest: PASS — the appliance builds the site and serves exactly what it built.

The bundled hugo-selftest command running end to end and reporting PASS across the extended binary, the fresh render, the SCSS compilation, the byte-for-byte match between disk and what nginx serves, the TLS certificate and the asset check

The asset check is the one that catches the failure a browser test misses: a page can return 200 while every stylesheet and image underneath it 404s. Requesting only the page cannot see that; requesting everything the page references can.

Troubleshooting

hugo-publish failed and my page is not live. That is the design working — the render is only swapped in once it has succeeded, so the previous site is still up. The error Hugo printed names the file and line. Fix it and run sudo hugo-publish again.

I edited a file and nothing happened. The timer sweeps every minute, so wait 60 seconds or run sudo hugo-publish to publish immediately. If neither works, check the watchers are running with systemctl is-active hugo-publish.timer hugo-publish.path and read sudo journalctl -u hugo-publish.service.

A new page does not appear even after publishing. Hugo skips pages with draft = true in their front matter, and pages with a date in the future. Remove the draft line, check the date, and publish again.

The site is unstyled. The stylesheet is compiled from SCSS at publish time, so an unstyled page means the render did not produce it. Run sudo hugo-selftest — it fails explicitly if the compiled stylesheet is missing or if the binary is not the extended edition.

Links point at the wrong host. baseURL in hugo.toml is what Hugo bakes into the canonical tag, the RSS feed and the sitemap. First boot sets it to this VM's public address; change it when you point a domain at the site, and publish.

nginx will not start after a reboot. nginx will not start until /var/lib/cloudimg/hugo-bootstrap-ready exists, which first boot creates only after generating this VM's certificate and rendering the site. Check sudo systemctl status hugo-firstboot.service and sudo cat /var/log/cloudimg-firstboot.log.

Security Notes

  • There is no login, and that is intentional. A static site generator is a build tool: content is edited over SSH by someone who already holds shell access, and the generated site is public by design. This appliance ships no admin panel, no login and no upload form, so there is no default credential and nothing authenticated exposed to the internet. If you need an authenticated editing workflow, keep the content in a Git repository and pull it onto the VM.

  • No key material in the image. The TLS certificate and its private key are generated on your VM at first boot; the image contains neither. nginx is gated on a marker that first boot creates only after that has happened, so the appliance cannot serve before it has its own key material. The private key is root:root mode 0600.

  • The Hugo development server is never run. hugo server binds a live reload listener and watches the filesystem; it is a development tool and is not installed as a service here. nginx serves pre rendered bytes only.

  • The site source is not served. nginx's document root is /var/www/hugo/public. Your Markdown, templates, SCSS and hugo.toml sit one level above it and are unreachable over HTTP. Directory listing is off, so a directory without an index.html is a 404 rather than a file listing, and the nginx version is not advertised.

  • Hugo renders your own content with unsafe = true. Raw HTML in your Markdown is passed through, which is what you want when the author is you. If you ever accept Markdown from untrusted contributors, set unsafe = false in hugo.toml first.

  • Restrict SSH to your management network with an NSG rule, and leave 80 and 443 open only as widely as the site's audience requires.

  • Keep the OS patched. Unattended security upgrades are enabled. Update Hugo itself by replacing /usr/local/bin/hugo with a newer upstream release and running sudo hugo-publish --force.

Support

cloudimg provides 24/7 support with a guaranteed 24 hour response SLA for all Azure Marketplace images. Contact support@cloudimg.co.uk.

Upstream project documentation is at gohugo.io and the source is at github.com/gohugoio/hugo. Hugo is distributed under the Apache License 2.0 and is redistributed here unmodified; the licence text is retained on the image at /usr/local/share/hugo/LICENSE. cloudimg is not affiliated with or endorsed by the Hugo project.