Introduction#

We often focus on HTML minification or CSS bundling, treating it as the end of optimization. However, I’ve noticed that even the best-prepared files can load slowly if we forget about the HTTP server layer. That’s where untapped potential lies, worth exploring.

In my practice, two configuration elements prove crucial. First, GZIP compression. From my experience, it can reduce text file sizes by sixty to seventy percent. Second, proper Cache headers. They eliminate unnecessary requests on subsequent user visits. Today I’ll show you how to configure a server for a Hugo-based site to squeeze maximum performance from static files.

GZIP Compression#

Let’s start with GZIP. It’s an algorithm that lets us drastically reduce the weight of text files like HTML, CSS, JavaScript, or JSON files. Most importantly, it’s lossless. In Hugo-based projects I’ve analyzed, enabling this option reduces transferred data size by an average of sixty to seventy percent. It’s a gain worth taking.

Implementation: Panel or Configuration File?#

The configuration method depends on the infrastructure where you’re deploying your application. From my experience, we usually deal with one of two scenarios.

Scenario 1: Panel Configuration (Managed/Shared Hosting)#

On many shared hosting platforms, the matter is often trivial and doesn’t require writing a single line of code. I’ve noticed that providers often offer ready-made switches in their admin panels (whether in cPanel, DirectAdmin, or custom dashboards).

Before diving into files, I recommend logging into your hosting panel and looking for a “Website Optimization” section or directly “GZIP Compression”. Often one click is enough for the server to automatically start optimizing all text files.

Scenario 2: Standard Apache and .htaccess File#

However, if you’re working on a standard Apache environment or your hosting doesn’t offer a “magic button”, we need to take matters into our own hands. Here the standard is manual configuration of the .htaccess file.

I want to highlight one thing though: before you start, check in the documentation whether your provider supports the mod_deflate module from this file level. I’ve encountered cases where hosting providers deliberately ignore these settings in .htaccess, forcing use of the panel (see Scenario 1).

If you have the green light though, we start the process by creating an .htaccess file in your Hugo project’s static directory.

# GZIP Compression via mod_deflate
<IfModule mod_deflate.c>
  # Compress text files
  AddOutputFilterByType DEFLATE text/html
  AddOutputFilterByType DEFLATE text/css
  AddOutputFilterByType DEFLATE text/javascript
  AddOutputFilterByType DEFLATE application/javascript
  AddOutputFilterByType DEFLATE application/json
  AddOutputFilterByType DEFLATE application/xml
  AddOutputFilterByType DEFLATE image/svg+xml

  # Add Vary header for cache compatibility
  Header append Vary Accept-Encoding
</IfModule>

The deployment process is essentially maintenance-free. Hugo ensures that during the build, our .htaccess file is automatically moved to the public folder. All that is left for us is a standard deploy to the server.

Verifying the Results#

I never blindly trust configuration settings. That is why I always check if compression is actually working. I have a few proven methods for this.

Method 1: A Quick Terminal Test#

The fastest way I verify this is with the curl tool. I send a request with a header telling the server that I accept gzip encoding. Take a look at this example:

curl -I -H "Accept-Encoding: gzip" https://your-domain.com/style.css | grep content-encoding

If the configuration is correct, the server should respond with exactly this header:

Expected output:

content-encoding: gzip

Method 2: The “Visual” Test – Size Comparison#

It is even clearer when looking at the numbers. I often run a simple comparison script to see the real gain. I download the same file twice – once “raw” and once with compression – and count the bytes.

echo "Without GZIP:"
curl -s https://your-domain.com/style.css | wc -c

echo "With GZIP:"your-domain.com/style.css | wc -c

The results are usually impressive. Take my CSS file as an example:

Without GZIP:
24781
With GZIP:
5384

We go down from over 24 kilobytes to just 5. This is exactly the optimization we are after.

Here is the continuation of the post, drafted in English:

Let’s continue:

Cache Headers#

The second pillar of performance is Cache Headers. While GZIP reduces the weight of the shipment, Cache Headers ensure the delivery driver doesn’t have to knock on your door at all. Technically speaking, these are instructions for the browser that define how long it can store a local copy of a given file. A good configuration eliminates unnecessary requests on subsequent visits, which drastically offloads the server and speeds up page loading.

Caching Strategy in Hugo#

In Hugo projects, I use a strategy based on how files are generated. The key here is the fingerprinting mechanism, which Hugo handles natively. Let’s take a closer look at this.

Fingerprinted Assets (CSS/JS)#

When Hugo builds assets, it appends a hash generated from the file’s content to the filename. It looks something like this: bundle.min.b8ee5840c5ea050eecdf3b702643ce8213a5b7388d2aa71f.css. This gives us immense peace of mind. Since every single change in the CSS code generates a completely new hash—and therefore a new URL—we can set very aggressive caching for these files.

  • Recommendation: Cache for 1 year (immutable).
  • Why? The old file can sit in the user’s cache indefinitely. When we deploy changes, the browser will ask for a new file anyway because the filename in the HTML changes. This is the safest and most efficient method.

HTML Files#

Things are different with HTML. It is our “entry point,” and it embeds the links to all those hashed CSS and JS files.

  • Recommendation: Cache for 1 hour (must-revalidate).
  • Why? We need to find a balance. If we cache HTML for a week and change styles in the meantime, the user will load old HTML pointing to old (and possibly non-existent) CSS for a whole week. A short cache lifespan guarantees that users quickly receive new versions of the site.

Images#

Here I recommend a compromise approach. Images are less frequently versioned through hashing, although it is possible in Hugo; we often just keep them in the static directory.

  • Recommendation: Cache for 1 week.
  • Why? Sometimes we swap a graphic for a better-optimized one, like converting PNG to WebP, under the same name. A weekly cache is the sweet spot between saving bandwidth and maintaining flexibility for deploying fixes.

Fonts#

Fonts are the most stable element of the frontend.

  • Recommendation: Cache for 1 year (immutable).
  • Why? The fira-code.woff2 file practically never changes. There is no point in the user downloading it more than once.

.htaccess Configuration#

Since we have a plan, let’s move to execution. Just like with compression, we create an .htaccess file in the static directory of our Hugo project.

# ===================================================================
# Performance optimization - Cache headers
# ===================================================================

# Fingerprinted assets (CSS/JS with hashes) - cache forever
<IfModule mod_expires.c>
  ExpiresActive On

  # CSS/JS with fingerprints - 1 year
  ExpiresByType text/css "access plus 1 year"
  ExpiresByType application/javascript "access plus 1 year"

  # Images - 1 week (shorter cache for flexibility)
  ExpiresByType image/jpeg "access plus 1 week"
  ExpiresByType image/png "access plus 1 week"
  ExpiresByType image/webp "access plus 1 week"
  ExpiresByType image/svg+xml "access plus 1 week"

  # Fonts - 1 year (rarely change)
  ExpiresByType font/woff2 "access plus 1 year"
  ExpiresByType font/woff "access plus 1 year"

  # HTML - SHORT cache (1 hour)
  # This ensures users get new CSS/JS URLs quickly when you deploy
  ExpiresByType text/html "access plus 1 hour"
</IfModule>

# Modern Cache-Control headers (preferred over Expires)
<IfModule mod_headers.c>
  # Fingerprinted assets - immutable
  # Pattern: bundle.min.[hash].css or main.[hash].js
  <FilesMatch "\.(css|js)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  # Images - 1 week (604800 seconds)
  <FilesMatch "\.(jpg|jpeg|png|gif|webp|svg)$">
    Header set Cache-Control "public, max-age=604800"
  </FilesMatch>

  # Fonts
  <FilesMatch "\.(woff|woff2|ttf|eot)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  # HTML - must revalidate after 1 hour
  <FilesMatch "\.html$">
    Header set Cache-Control "public, max-age=3600, must-revalidate"
  </FilesMatch>
</IfModule>

After you create the config file, the rest is basically hands-off. Hugo automatically copies our .htaccess file into the public folder during the build. Then a standard deploy to the server is enough—for example using rsync—and our caching policy starts working.

What did we actually do? Header anatomy#

Before we jump into testing, I want you to understand what we told the browser. We used a few key directives that directly control caching behavior via the Cache-Control header.

First: public. This is a signal not only for the user’s browser, but also for intermediaries like CDNs (for example Cloudflare) that this response can be cached by shared caches.

Second, my favorite combo for static assets: max-age=31536000 and immutable. max-age=31536000 is simply one year expressed in seconds, and immutable tells the browser that the resource won’t change during that freshness window, so a reload should not trigger revalidation requests for that file. This saves a lot of time and server resources.

Is it safe? Yes—thanks to Hugo’s fingerprinting mechanism, which changes the filename when the file content changes. The logic is simple:

  • We had: bundle.min.OLD_HASH.css
  • We change the background color.
  • We get: bundle.min.NEW_HASH.css

The old file can sit in the browser cache for a year and it does not hurt us, because the site stops referencing it. A new build means a new URL, so the browser downloads the fresh version anyway. No conflicts.

For HTML files, we use must-revalidate. Here we can’t afford guesswork, because HTML is the entry point and it controls which asset URLs the browser will request. must-revalidate means the browser must revalidate a stale response with the origin server before using it.

Verifying the configuration#

Deploying is one thing, but confidence comes from verification. This is how I check that the headers are served correctly.

Terminal test#

I usually start with curl. I fetch headers for one of the CSS files to confirm that the “immutable” policy is in place:

curl -I https://your-domain.com/css/bundle.min.ABC123.css | grep cache-control

If everything went according to plan, you should see the full set of instructions:

cache-control: public, max-age=31536000, immutable

Live browser test#

My final test is always real browser behavior.

  1. Open Chrome DevTools and go to the Network tab.
  2. Make sure “Disable cache” is unchecked.
  3. Refresh the page twice.

On the second load, look at the Size column. If you see disk cache or memory cache for CSS and JS instead of a byte size, it means we hit the goal: the browser is not fetching these files from the server, it is loading them instantly from local cache.

Deployment#

Since we have the configuration ready, it’s time for deployment. The good news is that we don’t need to change our workflow. The .htaccess file we created in the static directory will be automatically moved by Hugo to the public folder during the build process.

In my daily work, I use a simple set of commands. First, I build the optimized version of the site, and then I synchronize it with the server:

# Build with full optimization (minification + garbage collection)
hugo --minify --gc

# Synchronization with the server (the --delete flag removes old files)
rsync -avzr --delete public/ user@server:/path/to/domain/

Thanks to this, .htaccess lands on production along with the rest of the files. The --delete flag in rsync is crucial here—it ensures that old, unused hashed files (like style.old-hash.css) are removed from the server, keeping your hosting clean. If you are interested in fully automating this process, for example using CI/CD, I described it in detail in a separate post on Deploy Hugo site by github actions.

Results: What did we gain?#

As engineers, we like concrete data, so let’s look at the numbers. I analyzed the impact of these changes on a sample project.

GZIP: Less data to transfer#

The first gain is seen in the weight of the transferred data. Applying GZIP to text files yields spectacular results. In my tests, the CSS file “slimmed down” by over 60%.

File TypeOriginal SizeSize after GZIPReduction
CSS24.7 KB5.6 KB77%
HTML19.6 KB4.9 KB75%

The conclusion is simple: the user downloads the same content but consumes significantly less bandwidth and waits less for the page to render.

Cache Headers: Silence on the network#

It gets even more interesting when we look at browser behavior on return visits. Here we fight to reduce the number of requests to zero.

Scenario without optimization (or with weak cache):#

Even if the file hasn’t changed, the browser often asks the server: “Do you have a newer version?”. The server replies “No” (code 304). This still takes time—each such request is about 100ms of latency. With a dozen files, this turns into a second of waiting for nothing.

Scenario with our configuration (Immutable):#

Thanks to immutable headers and max-age=1 year, the second visit looks completely different. The browser doesn’t connect to the server for styles or scripts at all.

  • First visit: Standard download (~100ms per file).
  • Second visit: 0 HTTP requests. Everything loads from the disk in 0ms.

It is this difference that makes the page feel instant.

Why does this work?#

Let’s summarize why this combination is so effective:

  1. GZIP is free performance: We reduce transfer by 60-70% without any loss of quality. This works automatically for every user.
  2. Cache Headers eliminate lag: For returning users, resource loading time drops to zero. Moreover, cache also works during navigation between subpages—CSS downloaded once serves the entire visit.
  3. Fingerprinting is safety: Because Hugo changes the filename with every edit (new hash), we don’t have to worry about the user seeing an old version of the page. We can aggressively cache files because every change is de facto a new URL.

Summary#

HTTP server optimization is a textbook example of “set and forget”. We configure it once, and we gain performance for years.

I’ve gathered this into a short ROI (Return on Investment) summary:

TechniqueEffectDifficulty Level
GZIP60-70% smaller transferVery Easy
Cache headersInstant loading for returning usersEasy
FingerprintingCaching safety (no collisions)Built-in to Hugo

I encourage you to spend these 15 minutes on configuration. Your server will rest easy, and users will definitely feel the difference. Finally, to be sure, check your configuration with this simple command:

curl -I -H "Accept-Encoding: gzip" https://your-domain.com/style.css | grep content-encoding

If you see gzip – good job. You have an optimized server.

Sources#