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
I used three distinct paths:
Source: /home/minhdb/docker/2023-blog
Published: /srv/minhdb-blog/current
Nginx: /var/www/minhdb.io
The Nginx container receives the published directory as a read-only bind mount:
volumes:
- /srv/minhdb-blog/current:/var/www/minhdb.io:ro
Its HTTPS server uses:
root /var/www/minhdb.io;
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://minhdb.io/"
The same Hugo 0.107.0 minified build then completed successfully. This also improves canonical URLs, structured data, feeds, and sitemaps.
Publishing the build
After a successful build, I synchronized the contents of public/:
rsync -a --delete \
/home/minhdb/docker/2023-blog/public/ \
/srv/minhdb-blog/current/
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 means the destination must be exact and dedicated to generated content.
Diagnosing the first Nginx 404
The host had both files:
/home/minhdb/docker/2023-blog/public/index.html
/srv/minhdb-blog/current/index.html
But the running Nginx container did not have:
/var/www/minhdb.io/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://minhdb.io/
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.