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;
latestdid 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.