Skip to content

Local preview

Preview changes with hugo server, build a deployable public/ with hugo –panicOnWarning, and need neither Node nor a CDN.

Two commands cover the daily work: hugo server previews changes locally, and hugo produces a public/ deployable to any static host. The prerequisite is Hugo Extended (0.160.1 or newer) on the machine, plus Go when the theme comes in as a Hugo Module. The build depends on no Node.js, npm or PostCSS — those serve only this repository’s own regression checks.

The preview server

From the site root (the directory holding hugo.yml):

Terminal
hugo server

Open http://localhost:1313/. Saving a file rebuilds and refreshes the browser, and switching Git branches triggers a rebuild too. The first start is slower: with the theme as a Hugo Module, Hugo has to download the module through Go into its cache, and every start after that reads the cache.

The switches worth knowing

-D / --buildDrafts , defaultoff
Also builds pages with draft: true
-F / --buildFuture , defaultoff
Also builds pages whose date / publishDate is in the future
-E / --buildExpired , defaultoff
Also builds pages whose expiryDate has passed
--disableFastRender , defaultoff
Re-renders the whole site on every change instead of incrementally
-M / --renderToMemory , defaultoff (writes to disk)
Renders in memory only, writing no public/
-N / --navigateToChanged , defaultoff
The browser jumps to whichever page you saved
--bind , default127.0.0.1
The listen address; use 0.0.0.0 to reach it from a LAN or outside a container
-p / --port , default1313
The listen port
--minify , defaultoff
Minifies the preview too, to reproduce production rendering
--printPathWarnings , defaultoff
Warns when two pages write to the same target path

The combination used while developing this site:

Terminal
hugo server -DFE \
  --disableFastRender --renderToMemory --minify \
  --printPathWarnings --logLevel info

-DFE is shorthand for -D -F -E, building drafts, future and expired pages together so a newly created page is visible while writing.

A change that did not take effect

Hugo enables fast render by default, rebuilding only what it judges affected. When editing layouts, configuration, data/, or a file pulled in by include, that judgement can miss, and the page appears unchanged. Three steps:

  1. Restart with --disableFastRender and see whether it comes back.
  2. Hard-refresh the browser (Cmd/Ctrl + Shift + R) to rule out browser cache.
  3. If it still does not, clear the caches and restart.

Reaching it from another device

hugo server listens on 127.0.0.1 only, so no other device can reach it. To preview on a phone or another machine:

Terminal
hugo server --bind 0.0.0.0 --port 1313 --baseURL http://192.168.1.10:1313/

--baseURL must be an address the other device can reach, or the page opens while CSS and the search index — anything using an absolute path — point at localhost.

Production build

Build deployable output with hugo, not hugo server:

Terminal
hugo --gc --minify --printPathWarnings --panicOnWarning

The output goes to public/, which can be deployed independently of the source tree. Each of the four switches does one thing:

--gc
Clears cached resources in resources/_gen that are no longer referenced
--minify
Minifies the HTML, CSS, JS and XML output
--printPathWarnings
Warns when two pages collide on one output path, the commonest silent error on a multilingual site
--panicOnWarning
Fails the build on the first WARNING

--panicOnWarning deserves its own note. Most of OINK’s degradation paths warn rather than error: a missing required giscus key, an unsupported params.comments.type, a configuration key Hugo has deprecated — each prints one WARNING and moves on. CI logs are rarely read line by line, so those reach production. Putting this switch in the build command makes zero warnings the condition for a passing build.

This site’s CI build step (.github/workflows/pages.yml) is hugo --cleanDestinationDir --gc --minify --environment production --printPathWarnings --panicOnWarning, so any warning stops the deployment at the build stage.

baseURL and the build environment

baseURL lives in hugo.yml and can be overridden on the command line:

hugo.yml
baseURL: https://oink.pgsty.com
Terminal
hugo --minify --baseURL "https://example.com/docs/"

When deploying to a subpath, --baseURL must include that path segment; the details are in Deploy.

The build environment is chosen with -e / --environment; hugo defaults to production and hugo server to development. That choice has three visible consequences in OINK:

  • Only production emits <meta name="robots" content="index, follow">; other environments emit noindex, nofollow.
  • Under production robots.txt is Allow: /; elsewhere it is Disallow: /.
  • Only production renders Hugo’s Google Analytics template, and only there are static assets fingerprinted with SRI.

Build preview deployments (PR previews, staging) with a non-production environment, and the output declines indexing and analytics by itself:

Terminal
hugo --minify --environment staging --baseURL "$PREVIEW_URL"

Previewing in a container

A container is not required. Two situations suit one: a team that needs a pinned toolchain version, or one that would rather not install Hugo on every developer machine.

Dockerfile
FROM debian:bookworm-slim

ARG HUGO_VERSION=0.164.0
ARG GO_VERSION=1.26.6
ARG TARGETARCH

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git \
    && curl -L -o /tmp/hugo.deb \
      "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-${TARGETARCH}.deb" \
    && apt-get install -y /tmp/hugo.deb \
    && curl -L -o /tmp/go.tgz \
      "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" \
    && tar -C /usr/local -xzf /tmp/go.tgz \
    && rm -rf /var/lib/apt/lists/* /tmp/hugo.deb /tmp/go.tgz

ENV PATH="/usr/local/go/bin:${PATH}"
WORKDIR /src
EXPOSE 1313
ENTRYPOINT ["hugo"]
CMD ["server", "--bind", "0.0.0.0", "--disableFastRender"]
Terminal
docker build -t oink-hugo .

# preview: mount the site source, and the Go module cache with it
docker run --rm -it -p 1313:1313 \
  -v "$PWD:/src" \
  -v "$HOME/go/pkg/mod:/root/go/pkg/mod" \
  oink-hugo

# production build: override the default server command
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$PWD:/src" \
  oink-hugo --gc --minify

Go is in the image because Hugo needs it to resolve and download the module when the theme comes in as a Hugo Module. A site using a submodule, an offline archive or a plain clone can drop Go, and the image gets much smaller.

Do not let root write public/

A container process is root by default, the generated public/ belongs to root, and the host cannot delete it. In a shared environment, map the user ID with --user "$(id -u):$(id -g)" (the production build command above already does).

The image needs no Node.js, npm or PostCSS, and should have no step fetching remote browser assets. A network-isolated environment needs the base image and those two packages mirrored in advance.

Clearing caches

Hugo’s intermediate output lives in three places; clear them lightest first:

public/ , ContentsThe previous build’s output
A page was deleted but is still live; or let the build clear it with hugo --cleanDestinationDir
resources/_gen/ , ContentsProcessed images and compiled CSS
Image processing parameters, fonts or the accent colour changed and the page still looks old
hugo mod clean , ContentsThe Hugo Module cache
The theme version changed but the old one still resolves; add --all to clear the whole module cache
Terminal
rm -rf public resources/_gen
hugo mod clean          # only the modules this project uses
hugo mod clean --all    # the whole module cache; the next build downloads again

Both public/ and resources/ belong in .gitignore; generated output is never committed.

Working on the theme alongside

This section applies only when changing the theme and the site together. Point the module at a local checkout temporarily with HUGO_MODULE_REPLACEMENTS, leaving go.mod untouched:

Terminal
HUGO_MODULE_REPLACEMENTS='github.com/pgsty/oink -> /absolute/path/to/oink' hugo server

This site’s Makefile wraps those commands and expects the theme checkout at the sibling ../oink:

Makefile targets
make dev     # development server with ../oink substituted
make check   # full regression suite (npm test) with ../oink substituted
make build   # build with the version in go.mod
make serve   # preview server with the production configuration
A replacement is local only

Whether through the environment variable or a Go workspace (go work init plus HUGO_MODULE_WORKSPACE=go.work), CI and production builds read only go.mod; go.work records a developer machine’s paths and is never committed. To judge whether a release tag works, drop the replacement and build once against the version in go.mod.

Verifying an offline build

Acceptance in a network-isolated environment has to cover both the build stage and the browser stage. Six steps:

  1. Start from a verified theme archive and an empty module cache (hugo mod clean --all).
  2. Block outbound HTTP, HTTPS and the Go module proxy.
  3. Run the production build hugo --gc --minify --printPathWarnings --panicOnWarning.
  4. Browse pages in both languages: a documentation page, a blog page, the home page, the 404.
  5. Exercise search, the light/dark toggle, diagrams and content components.
  6. Check subresource origins and confirm there is no unexpected remote host.

The last step uses a script from the theme repository that does not depend on the site’s test framework:

Terminal
python3 bin/check-output-security.py \
  --public public --base-url https://docs.internal.example.com/

The script scans every href / src / srcset / poster and form action in all four outputs, requiring each to be a site-relative path or http / https / mailto / tel, and rejecting inline on* handlers and javascript: URLs. An <iframe>, <script>, <link>, <img>, <video>, <audio>, <embed>, <object> or <source> pointing at another host is an error; where a site genuinely embeds third-party content, --third-party permits it, and a multi-domain language configuration adds first-party hosts with --allow-host.

One pass proves that commit in that environment. Run it again for every theme candidate and after every bundled-dependency update.

Verify

A clean production build should look like this:

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning

It passes on Total in … with no ERROR and no WARNING. Then confirm:

  • The log has no npm, PostCSS, Autoprefixer or browser-asset download step. One appearing means upstream Docsy’s process has crept into the configuration.
  • public/ has sitemap.xml and robots.txt, and robots.txt reads Allow: /.
  • On a site with local search, public/ has offline-search-index.<language>.json at its root.
  • Open representative pages with hugo server: one documentation page, one blog page, the home page and the 404, in both languages and both colour schemes.

For a failing build or a wrong result, see Troubleshooting.