Moving away from the development server

My original Compose service ran:

hugo:
  image: klakegg/hugo:latest
  command: server
  volumes:
    - "./2023-blog/:/src"

hugo server is useful during development, but a production static site does not need a long-running Hugo process. A cleaner design is:

Git source
  -> one-time Hugo build
  -> versioned static output
  -> read-only Nginx document root

The design uses three distinct paths. These public examples intentionally use generic names:

Source:     /opt/example-blog/source
Published:  /srv/example-blog/current
Nginx:      /var/www/example-blog

The Nginx container receives the published directory as a read-only bind mount:

volumes:
  - /srv/example-blog/current:/var/www/example-blog:ro

Its HTTPS server uses:

root /var/www/example-blog;
index index.html;

location / {
    try_files $uri $uri/ =404;
}

Reproducing the Hugo failure

The production build failed only with minification enabled:

docker compose run --rm --no-deps hugo --minify

Hugo 0.107.0 reported JSON parse errors in several generated pages. The unminified build completed, so I inspected the generated HTML and extracted each application/ld+json block.

The malformed data was a PaperMod breadcrumb list containing adjacent objects without a comma:

{
  "@type": "ListItem"
}
{
  "@type": "ListItem"
}

The immediate source was not the page content. The site configuration used:

baseURL: "/"

That old PaperMod template removed site.BaseURL from parent permalinks while constructing breadcrumbs. With / as the base, it also removed path separators and caused the template's comma condition to fail.

The production fix was to declare the canonical URL:

baseURL: "https://example.com/"

The same Hugo 0.107.0 minified build then completed successfully. This also improves canonical URLs, structured data, feeds, and sitemaps.

This was a compatibility fix for the theme version in this repository, not a claim that every JSON-LD error is caused by baseURL. The reusable method was to reproduce with the production Hugo version, build without minification for inspection, extract the generated JSON-LD, and validate the malformed block before editing a template or configuration.

Publishing the build

After a successful build, I synchronized the contents of public/:

set -euo pipefail

SOURCE_DIR=/opt/example-blog/source
DEPLOY_DIR=/srv/example-blog/current

test "$DEPLOY_DIR" = "/srv/example-blog/current"
test -s "$SOURCE_DIR/public/index.html"
test -d "$DEPLOY_DIR"

rsync -a --delete \
  "$SOURCE_DIR/public/" \
  "$DEPLOY_DIR/"

The trailing slashes matter: this copies the contents of public into current, rather than creating current/public.

--delete prevents stale pages from surviving after source files are removed. It also makes a bad destination dangerous. The literal-path assertion, source index.html check, and destination-directory check ensure that the command operates only on the dedicated deployment directory.

Diagnosing the first Nginx 404

The host had both files:

/opt/example-blog/source/public/index.html
/srv/example-blog/current/index.html

But the running Nginx container did not have:

/var/www/example-blog/index.html

The Compose file contained the new mount, but the container had been created before that change. Restarting an existing container does not change its mount definition. I recreated only Nginx:

docker compose up -d --force-recreate nginx

The file then appeared inside the container, and:

curl -I https://example.com/

returned:

HTTP/2 200

Operational lessons

  • Pin the Hugo image or version; latest did not mean a recent Hugo release in this old image.
  • Build with the same version in development and production.
  • Treat minification failure as validation, not an inconvenience to disable.
  • Keep source and published files separate.
  • Mount generated content read-only into the web server.
  • Recreate containers when ports, volumes, or environment variables change.
  • Verify files from both the host and container perspectives.

The resulting architecture is simpler than proxying a development server and gives Nginx exactly what it is designed to serve: static files. The complete commented virtual-host configuration belongs in A self-host life; this article stays focused on the Hugo build failure and the source-to-output boundary.