Hugo on Ubuntu 24.04 on Azure User Guide
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.pathandhugo-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

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 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/.

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

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.

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'

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.pathreacts within seconds to a change directly undercontent/or tohugo.tomlhugo-publish.timersweeps 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 undercontent/becomes a section:content/notes/serves at/notes/and gets an index page fromlayouts/section.html. - Look —
assets/scss/main.scssis real SCSS with variables, nesting and mixins. Change$navyor$accentat the top and publish; the transpiler recompiles it. - Templates —
layouts/baseof.htmlis the page shell.home.html,page.htmlandsection.htmlfill in itsmainblock.

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 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:rootmode0600. -
The Hugo development server is never run.
hugo serverbinds 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 andhugo.tomlsit one level above it and are unreachable over HTTP. Directory listing is off, so a directory without anindex.htmlis 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, setunsafe = falseinhugo.tomlfirst. -
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/hugowith a newer upstream release and runningsudo 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.