livereload.js Making It to Production During `hugo deploy`: Causes and Solutions
10 min read

livereload.js Making It to Production During `hugo deploy`: Causes and Solutions

In Hugo, livereload.js should only appear in development mode — this file is responsible for making the browser automatically refresh every time code changes, a feature very helpful when writing content or modifying templates. But it’s not uncommon for this file to make it into the production deployment even when the deployment uses hugo deploy, Hugo’s official publishing command. This problem is often mistaken for a Hugo bug, when the root cause is a misunderstanding of the build and deploy flow, which are actually strictly separated. This article dissects the mechanism behind livereload injection, why hugo deploy never rebuilds, and how to set up a workflow that makes this problem impossible to happen again.

The Conceptual Separation: Build vs Deploy in Hugo

Hugo strictly separates these two processes, and understanding this separation is the key to the entire discussion in this article:

ProcessFunction
BuildGenerates static files into the public/ folder
DeployUploads the contents of public/ as-is to the remote
flowchart LR
    A[Source: content, layouts, config] -->|hugo / hugo server| B[public/ folder]
    B -->|hugo deploy| C[Remote: S3, GCS, etc.]
    style B fill:#f9f9f9

The commands related to both processes:

CommandRole
hugoBuild for production
hugo serverBuild for development + LiveReload active
hugo deployOnly uploads the contents of public/, doesn’t build

One crucial fact that’s the root of all problems in this article: hugo deploy never rebuilds. It’s purely an upload process — whatever is in the public/ folder when this command runs is what gets sent to the remote, whether its contents are valid for production or not.


How Hugo Injects livereload.js During hugo server

Before discussing the problem’s cause, it’s important to understand the mechanism behind why livereload.js can appear in HTML output at all. This isn’t a static file you deliberately placed in the static/ folder — Hugo injects it automatically into every HTML page while the development server runs.

sequenceDiagram
    participant Dev as Developer
    participant Hugo as hugo server
    participant Browser
    Dev->>Hugo: Run hugo server
    Hugo->>Hugo: Set .Site.IsServer = true
    Hugo->>Hugo: Render HTML + inject <script src="/livereload.js">
    Hugo->>Browser: Serve the page with livereload active
    Dev->>Hugo: Change content/layout files
    Hugo->>Browser: Send signal via WebSocket
    Browser->>Browser: Auto-refresh the page

The mechanism depends on one internal variable: .Site.IsServer. This variable is true only when Hugo is run via hugo server, and false for regular builds via hugo. When .Site.IsServer is true, Hugo injects a <script> element pointing to livereload.js — a small script that opens a WebSocket connection to the development server, listens for file change signals, then triggers an automatic refresh in the browser.

Because this injection is automatic and happens at Hugo’s engine level (not something you write manually in a template, unless you added it yourself), many developers don’t realize this script is truly embedded directly in the built HTML — not loaded separately via an additional request that’s easy to overlook.

livereload.js itself is actually served directly by the running hugo server process, not a physical file stored in the public/ folder. What gets deployed to production isn’t the livereload.js file, but the <script> tag reference to that file in the built HTML — which in production will fail to load (404) while also being an indication that the uploaded output isn’t a correct production build.

Why livereload.js Makes It to Production

The most frequent case follows a workflow pattern like this:

hugo server
# (stop the server with Ctrl+C)
hugo deploy
sequenceDiagram
    participant Dev as Developer
    participant FS as public/ folder
    participant Remote
    Dev->>FS: hugo server (fills public/ with livereload.js injected)
    Dev->>Dev: Stop the server, public/ is NOT cleaned
    Dev->>Remote: hugo deploy
    Note over Remote: Uploads the contents of public/ as-is<br/>including the livereload.js script

The step-by-step explanation:

  1. hugo server builds HTML in development mode, and automatically injects livereload.js because .Site.IsServer is true
  2. This output is stored to the public/ folder on disk — same as a regular hugo build result
  3. hugo deploy is then run, and because it never rebuilds (as explained in the previous section), it assumes the contents of public/ are valid and uploads them directly
  4. As a result, development artifacts — complete with the livereload.js reference — get sent to production

The root cause isn’t hugo deploy misbehaving, but the wrong assumption that hugo deploy would “know” to rebuild a production version first. According to the conceptual separation in the previous section, deploy is indeed purely an upload task.


The Meaning of the “No changes required.” Message

When running hugo deploy, sometimes this message appears:

No changes required.

This message is often misinterpreted as an error or a sign something’s wrong, when it actually means the opposite — the deploy ran normally, only there were no changes to upload. Behind the scenes, hugo deploy compares the local public/ folder contents with what’s already on the remote, usually based on per-file checksums or hashes, then only uploads files that differ.

flowchart TD
    A[hugo deploy is run] --> B[Compute the hash of every file in local public/]
    B --> C[Compare with the hashes of files on the remote]
    C --> D{Any differences?}
    D -- No --> E["No changes required."]
    D -- Yes --> F[Upload only the differing files]

This message is not an error — but this is exactly where the trap appears. If the public/ folder being compared contains build results from hugo server (complete with livereload.js), and those contents happen to have been uploaded before, Hugo still considers it a valid and consistent state — even though the wrong artifact was already considered final since the first upload.


The Role of the Environment: development vs production

Hugo determines many build behaviors — including whether livereload is injected — based on the active environment. It’s important to understand that this environment is actually just a plain string read via hugo.Environment in templates, not a magic switch mechanism that automatically changes behavior without explicit conditions in the code.

Check the currently active environment:

hugo env

Expected output for a production build:

Environment: production

Hugo’s default environment actually depends on the command used:

CommandDefault Environment
hugoproduction
hugo serverdevelopment
Manual overrideHUGO_ENV=production hugo ...

If the active environment is still development when you intend to build for production — whether from forgetting, or from a custom build script that doesn’t explicitly set the environment — the consequences are:

  • LiveReload can still potentially be active depending on template conditions
  • .Site.IsServer could still be true if the process came from hugo server
  • The risk of livereload.js making it into production output becomes very high
.Site.IsServer and hugo.Environment are two different things even though they often seem related. .Site.IsServer is purely about whether the process came from hugo server or not, while hugo.Environment is a string label that can be manually overridden via HUGO_ENV. Both should ideally be checked together in templates for truly safe conditions, not relying on just one of them.

Common Mistakes That Often Happen

Assuming hugo deploy Automatically Rebuilds

# ANTI-PATTERN: assuming hugo deploy will rebuild first
hugo deploy
# No rebuild happens -- public/ is uploaded as-is

# CORRECT: build explicitly before deploying
hugo --minify
hugo deploy

Deploying After hugo server

# ANTI-PATTERN: public/ still contains development artifacts
hugo server
# stop the server
hugo deploy

# CORRECT: clean and rebuild before deploying
rm -rf public
hugo --minify
hugo deploy

Using –minify with hugo deploy

# ANTI-PATTERN: --minify isn't a flag for hugo deploy
hugo deploy --minify
# Error: unknown flag: --minify

# CORRECT: --minify is used during build, not deploy
hugo --minify
hugo deploy

These three mistakes actually stem from the same misunderstanding: treating hugo deploy as having abilities or responsibilities that actually belong to the hugo command (build), even though Hugo deliberately separates them as two independent commands.


The Correct Build and Deploy Workflow

The safe production sequence combines three things at once: cleaning old artifacts, rebuilding with the correct environment, then deploying.

rm -rf public && HUGO_ENV=production hugo --minify && hugo deploy
flowchart TD
    A[rm -rf public] --> B[HUGO_ENV=production hugo --minify]
    B --> C{Build successful?}
    C -- No --> D[Fix the error, repeat from the start]
    C -- Yes --> E[hugo deploy]
    E --> F[Verify: grep livereload in public/]

Explanation of each step:

  • rm -rf public removes all old artifacts, including possible leftovers from a previous hugo server — this step directly eliminates the root cause
  • HUGO_ENV=production hugo --minify rebuilds from scratch with the environment explicitly set to production, ensuring .Site.IsServer is false
  • hugo deploy uploads the clean output that truly reflects a production build
Make this command sequence a single script (for example deploy.sh or a target in a Makefile), not typed manually every time. Removing manual steps is the same as removing the possibility of forgetting one of the stages.

Verification Before Deploying

Before running hugo deploy, do an explicit check to make sure no livereload.js reference is left in the output:

grep -R "livereload" public/

If nothing appears, the output is safe for production. If something appears, that’s a clear sign the public/ folder still contains artifacts from hugo server and needs rebuilding before the deploy continues.

For teams running deploys more than once or involving more than one person, consider adding this check as an explicit step in the deploy script itself — not just remembered manually every time:

#!/bin/bash
set -e

rm -rf public
HUGO_ENV=production hugo --minify

if grep -R "livereload" public/ > /dev/null; then
  echo "ERROR: livereload.js detected in the production build, deploy cancelled."
  exit 1
fi

hugo deploy

With set -e and an explicit check before the hugo deploy line, this script automatically stops if an indication of a wrong build is found — preventing a wrong deploy from happening unnoticed, instead of relying only on manual vigilance.


Best Practice for Injecting LiveReload in Templates

If you’ve ever added livereload manually in a template (not relying on Hugo’s automatic injection), avoid hardcoding it without any condition at all:

<!-- ANTI-PATTERN: livereload always included without a condition -->
<script src="/livereload.js"></script>

The correct approach uses a conditional based on .Site.IsServer:

{{ if .Site.IsServer }}
  {{ partial "livereload.html" . }}
{{ end }}

For an additional security layer, combine it with an explicit environment check — remembering as discussed earlier, .Site.IsServer and hugo.Environment are two different things and ideally should be checked together:

{{ if and .Site.IsServer (eq hugo.Environment "development") }}
  {{ partial "livereload.html" . }}
{{ end }}

With this combination of two conditions, livereload will only appear when both are true — the process came from hugo server and the active environment is indeed development. This provides an additional protection layer compared to relying on just one condition, especially for complex setups where the environment can be manually overridden.

The two-condition combination (.Site.IsServer and hugo.Environment) is safer than a single condition, but still doesn’t replace the habit of cleaning the public/ folder before a production build. Layered defenses are always better than relying on a single mechanism.

Command and Behavior Summary Table

CommandRebuilds?.Site.IsServerDefault Environmentlivereload.js Risk
hugo serverYes, continuouslytruedevelopmentAlways injected (as intended)
hugoYes, oncefalseproductionNone, unless hardcoded in templates
hugo --minifyYes, oncefalseproductionNone
hugo deployNoNot relevantNot relevantDepends entirely on the previous public/ contents

Summary

  • Hugo strictly separates build (generating public/) and deploy (uploading public/) — hugo deploy never rebuilds.
  • livereload.js is automatically injected into HTML when .Site.IsServer is true, which only happens when the process comes from hugo server.
  • The most common cause is running hugo deploy after hugo server without rebuilding in between — development artifacts get uploaded as-is.
  • The “No changes required.” message isn’t an error, but can be misleading if the artifacts being compared were already wrong since the first upload.
  • hugo.Environment is a string read from HUGO_ENV or the command default, different from .Site.IsServer — both ideally should be checked together in templates for the safest conditions.
  • Safe workflow: rm -rf public to clean old artifacts, rebuild explicitly with the production environment, then run hugo deploy.
  • grep -R "livereload" public/ is a quick verification before deploying — consider making it an automatic step in the deploy script, not just remembered manually.
  • In templates, use {{ if and .Site.IsServer (eq hugo.Environment "development") }} for layered protection against ambiguous environment conditions.

Portfolio