Creating a Hugo 404 Page and Serving It with Nginx

This post covers how to add a 404 page to a Hugo site.

In Hugo, if you create layouts/404.html, Hugo generates public/404.html during the build. However, if the production site is served by Nginx, Nginx also needs to be configured to return /404.html when a 404 occurs.

The work is split into two parts:

  1. Generate public/404.html on the Hugo side
  2. Configure Nginx to return /404.html for 404 responses

Create the 404 Template in Hugo

Create layouts/404.html at the root of the Hugo project.

For this site, I wanted the 404 page to use the same layout as normal pages, so I only defined the main block.

{{ define "title" }}404 Not Found | {{ site.Title }}{{ end }}

{{ define "main" }}
  <article class="article not-found">
    <header class="article-header entry-header">
      <h1 class="entry-title">404 Not Found</h1>
    </header>
    <div class="entry-content cf">
      <p>{{ T "not_found_message" }}</p>
      <p><a class="button-link" href="{{ site.Home.RelPermalink }}">{{ T "back_to_home" }}</a></p>
    </div>
  </article>
{{ end }}

The text is handled through i18n.

Add the following entries to i18n/ja.toml:

[not_found_message]
other = "ページが見つかりませんでした。URLが変わったか、ページが削除された可能性があります。"

[back_to_home]
other = "ホームへ戻る"

Add the same keys to i18n/en.toml:

[not_found_message]
other = "The page could not be found. The URL may have changed or the page may have been removed."

[back_to_home]
other = "Back to home"

Check That Hugo Generates the File

After adding the template, build the Hugo site.

hugo --environment production --minify --cleanDestinationDir

Check that public/404.html was generated.

ls -l public/404.html

On Windows, you can check it from PowerShell.

Get-Item public\404.html

If public/404.html exists, the Hugo side is ready.

Configure Nginx to Return 404.html

In production, this site serves Hugo’s public directory through Nginx.

In my setup, hugo-public is mounted into the Nginx container with Docker Compose. Inside the container, it is available as /var/www/html.

In that case, the Nginx server block should look like this:

server {
    server_name nosubject.io;

    root /var/www/html;
    index index.html;

    error_page 404 /404.html;

    location = /404.html {
        internal;
    }

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

The important line is this:

error_page 404 /404.html;

When try_files cannot find a matching file, Nginx returns a 404. With error_page configured, Nginx uses /404.html as the response body for that 404.

The location / block serves Hugo’s static files.

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

location = /404.html { internal; } marks the file as something used internally for error handling. It may work without this block, but I included it to make the intent clear.

Apply the Nginx Configuration

After editing the Nginx configuration, check the syntax first.

cd ~/nosubject-docker
docker compose exec web nginx -t

If there are no errors, reload Nginx.

docker compose exec web nginx -s reload

If the service name is not web, check it first with docker compose ps.

docker compose ps

Check the Page and Status Code

Do not only check the page visually in a browser. Also check the HTTP status code with curl.

curl -I https://nosubject.io/this-page-does-not-exist/

The expected result is that the status code remains 404, while the response body is Hugo’s 404.html.

HTTP/2 404

To check against a local Nginx container, specify the Host header.

curl -k -I https://localhost/this-page-does-not-exist/ -H 'Host: nosubject.io'

If this returns 200 OK, nonexistent pages will be treated as normal pages. That is not ideal for search engines. The page should show a friendly 404 page, but the status code should still be 404.

Be Careful When Checking with hugo server

When using hugo server locally, you can open the 404 page directly.

http://localhost:1313/404.html

However, this is not the same as production Nginx error handling.

In production, what matters is whether Nginx returns public/404.html for nonexistent URLs. The final check should be done in an environment close to the production Nginx setup.

What to Check If It Does Not Work

If the default Nginx error page appears, it does not necessarily mean that 404.html is missing. It may mean that Nginx is not configured to use that file as the error page.

First, check that 404.html exists in the public directory.

ls -l ~/nosubject-docker/hugo-public/404.html

Then check whether the Nginx configuration includes error_page 404 /404.html;.

docker compose exec web nginx -T

With only try_files $uri $uri/ =404;, Nginx returns a 404 status, but it does not use Hugo’s 404.html as the response body.

Summary

The steps for showing a Hugo 404 page through Nginx are:

  1. Create layouts/404.html
  2. Run hugo and confirm that public/404.html is generated
  3. Add error_page 404 /404.html; to the production Nginx configuration
  4. Check the Nginx configuration with nginx -t
  5. Reload Nginx
  6. Open a nonexistent URL and check both the displayed page and the HTTP status code

With a static site, generating the file with Hugo and serving that file for error responses are separate pieces of configuration.

If public/404.html is generated but the production site still shows the default Nginx error page, the fastest place to check is the Nginx error_page setting.