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:
| Process | Function |
|---|---|
| Build | Generates static files into the public/ folder |
| Deploy | Uploads 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:#f9f9f9The commands related to both processes:
| Command | Role |
|---|---|
hugo | Build for production |
hugo server | Build for development + LiveReload active |
hugo deploy | Only 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 pageThe 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.jsitself is actually served directly by the runninghugo serverprocess, not a physical file stored in thepublic/folder. What gets deployed to production isn’t thelivereload.jsfile, 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 scriptThe step-by-step explanation:
hugo serverbuilds HTML in development mode, and automatically injectslivereload.jsbecause.Site.IsServeristrue- This output is stored to the
public/folder on disk — same as a regularhugobuild result hugo deployis then run, and because it never rebuilds (as explained in the previous section), it assumes the contents ofpublic/are valid and uploads them directly- As a result, development artifacts — complete with the
livereload.jsreference — 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:
| Command | Default Environment |
|---|---|
hugo | production |
hugo server | development |
| Manual override | HUGO_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.IsServercould still betrueif the process came fromhugo server- The risk of
livereload.jsmaking it into production output becomes very high
.Site.IsServerandhugo.Environmentare two different things even though they often seem related..Site.IsServeris purely about whether the process came fromhugo serveror not, whilehugo.Environmentis a string label that can be manually overridden viaHUGO_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 publicremoves all old artifacts, including possible leftovers from a previoushugo server— this step directly eliminates the root causeHUGO_ENV=production hugo --minifyrebuilds from scratch with the environment explicitly set to production, ensuring.Site.IsServerisfalsehugo deployuploads the clean output that truly reflects a production build
Make this command sequence a single script (for exampledeploy.shor a target in aMakefile), 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.IsServerandhugo.Environment) is safer than a single condition, but still doesn’t replace the habit of cleaning thepublic/folder before a production build. Layered defenses are always better than relying on a single mechanism.
Command and Behavior Summary Table
| Command | Rebuilds? | .Site.IsServer | Default Environment | livereload.js Risk |
|---|---|---|---|---|
hugo server | Yes, continuously | true | development | Always injected (as intended) |
hugo | Yes, once | false | production | None, unless hardcoded in templates |
hugo --minify | Yes, once | false | production | None |
hugo deploy | No | Not relevant | Not relevant | Depends entirely on the previous public/ contents |
Summary
- Hugo strictly separates build (generating
public/) and deploy (uploadingpublic/) —hugo deploynever rebuilds.livereload.jsis automatically injected into HTML when.Site.IsServeristrue, which only happens when the process comes fromhugo server.- The most common cause is running
hugo deployafterhugo serverwithout 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.Environmentis a string read fromHUGO_ENVor the command default, different from.Site.IsServer— both ideally should be checked together in templates for the safest conditions.- Safe workflow:
rm -rf publicto clean old artifacts, rebuild explicitly with the production environment, then runhugo 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.