···11+---
22+date: 2026-02-20
33+title: Optimizing a Hugo site's performance and security
44+tags:
55+ - clevercloud
66+ - security
77+ - devops
88+ - tools
99+---
1010+1111+Following the good advice from my friend [Antoine Caron](https://blog.slashgear.dev/), I took some time this week to optimize my site.
1212+1313+The site you're reading is a static site built with Hugo.
1414+1515+I had already done some work on compressing the various resources, mainly images, but I stopped there.
1616+In this article, I detail how I optimized the build of this site to minimize loading times, and how I improved its security by following the best practices promoted by MDN.
1717+1818+<!-- more -->
1919+2020+## The Lighthouse score
2121+2222+To do initial work on this site's performance, I used a [Lighthouse analysis](https://pagespeed.web.dev/analysis/https-codeka-io/we5dukzmku?form_factor=desktop) (quite standard).
2323+2424+Lighthouse lets you get a view of an application or website's performance in just a few minutes, for both desktop and mobile targets.
2525+It also lets you validate certain accessibility properties, such as contrast, presence of alternative text for screen readers, etc.
2626+2727+I think it's a good starting point.
2828+2929+Here are my site's current scores, for mobile and desktop browsing:
3030+3131+
3232+
3333+{ class="images-grid-2" }
3434+3535+These scores may seem interesting on the homepage, but they degrade significantly on certain pages.
3636+Here are the scores for my Factorio talk page:
3737+3838+
3939+
4040+{ class="images-grid-2" }
4141+4242+> I clearly have room for improvement on accessibility and performance. Things aren't going well there at all.
4343+4444+Without going into detail and analyzing what this tool reports, let's dive right into the main topic.
4545+4646+## Minifying HTML, CSS, and JS
4747+4848+A first step involves minifying static resources: HTML, CSS, and JS.
4949+5050+This step is very simple to implement, as Hugo already supports it.
5151+You just need to add the `--minify` flag during the build to ask Hugo to minify all resources.
5252+5353+My build command in my `mise.toml` is as follows:
5454+5555+```toml
5656+[tasks.build]
5757+description = "Build the site with Hugo"
5858+run = "hugo --gc --minify --destination public"
5959+```
6060+6161+This produces minified HTML files like this one:
6262+6363+
6464+6565+No surprises or difficulties here, we can quickly move on to the next step 🚶
6666+6767+> I included this section for completeness; my static resources were already minified. But I wanted a complete approach and to verify this point too.
6868+6969+## Converting images to webp
7070+7171+I often use photos I've captured with my smartphone (for conference articles), screenshots or diagrams (made on draw.io most often), or stock photos I find to illustrate my veille (tech watch) articles.
7272+7373+These photos are often heavy (several megabytes) and high resolution, and a simple action is to resize these photos and recompress them in webp or avif format.
7474+7575+Not sure which format to use, I opted for webp for two reasons: Hugo supports [webp format natively](https://gohugo.io/functions/images/process/#format) (not avif), and avif support seemed slightly less than webp in browsers.
7676+7777+> It's quite possible I'll change my mind on this quickly and switch to avif as soon as Hugo supports it.
7878+7979+I started by converting my images to webp.
8080+I did this in one shot with a script using the `cwebp` CLI for Linux:
8181+8282+```shell
8383+# parallelize conversions to use all available CPUs
8484+JOBS="$(nproc)"
8585+# find images
8686+find . -type f -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \
8787+# convert to webp
8888+ | xargs -n 1 -P "$JOBS" -I IMG sh -c 'cwebp -q 75 IMG -o $(echo "IMG" | sed "s/\.[^.]*$/.webp/")'
8989+```
9090+9191+Then a big `sed` to replace the references in my markdown:
9292+9393+```shell
9494+sed -Ei 's/\.(jpe?g|png)$/\.webp/I' **/*.md
9595+9696+find . -type f -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' | rm
9797+```
9898+9999+The images are now in webp format, which will save some space and download time for readers.
100100+101101+I haven't calculated the size reduction, but for source images, we must be close to 60% of their original size:
102102+103103+```shell
104104+❯ ls -alh
105105+.rw-r--r-- jwittouck jwittouck 65 KB Tue Dec 30 12:17:33 2025 clever-addon-create.png
106106+.rw-rw-r-- jwittouck jwittouck 20 KB Fri Feb 20 10:28:43 2026 clever-addon-create.webp
107107+.rw-r--r-- jwittouck jwittouck 69 KB Tue Dec 30 12:17:33 2025 clever-env.png
108108+.rw-r--r-- jwittouck jwittouck 26 KB Fri Feb 20 10:28:43 2026 clever-env.webp
109109+.rw-r--r-- jwittouck jwittouck 48 KB Tue Dec 30 12:17:33 2025 clever-open-starting.png
110110+.rw-r--r-- jwittouck jwittouck 15 KB Fri Feb 20 10:28:43 2026 clever-open-starting.webp
111111+```
112112+113113+## Resizing to desired sizes
114114+115115+Hugo supports recompressing images in different formats on the fly (which could have replaced my scripts, but it was better not to do this at build time), but not automatic resizing - you have to implement the mechanism yourself.
116116+To be able to resize images on the fly (at build time), the best solution seems to be using a Hugo img hook, which allows you to override the markdown rendering and put whatever code you want there.
117117+118118+The default hook is as follows:
119119+120120+```go
121121+<img src="{{ .Destination | safeURL }}"
122122+ {{- with .PlainText }} alt="{{ . }}"{{ end -}}
123123+ {{- with .Title }} title="{{ . }}"{{ end -}}
124124+>
125125+```
126126+127127+An image declared in Markdown like this:
128128+129129+```markdown
130130+
131131+```
132132+133133+Will have the following HTML equivalent:
134134+135135+```html
136136+<img src="/photo.jpg" alt="An image">
137137+```
138138+139139+To resize images to a maximum width of 820px (the width used on this site's content column), I use the following hook:
140140+141141+```go
142142+{{- $image := .Page.Resources.GetMatch .Destination -}}
143143+144144+{{- $width := math.Min 820 $image.Width -}}
145145+{{- $resizeOpts := printf "%dx webp q75 lanczos" (int $width) -}}
146146+147147+{{- with $image.Resize $resizeOpts -}}
148148+<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}"
149149+ {{- with $.PlainText }} alt="{{ . }}"{{ end -}}
150150+ {{ with $.Title }}title="{{ . }}"{{ end }}>
151151+{{- end -}}
152152+```
153153+154154+The magic happens in the first few lines.
155155+I resize the image to a maximum width of 820px (or less if the image is smaller).
156156+157157+The HTML generated by Hugo for my images is now as follows:
158158+159159+```html
160160+<img src="/photo_hu_ed495de5ae801a42.webp" width="820" height="540" alt="An image">
161161+```
162162+163163+With resizing and webp conversion, I'm optimizing images for display in my site's format, at build time, while keeping the images in webp at their original resolution.
164164+165165+I can go even further by working with a `srcset` to offer the browser different sized images depending on the viewport size, which prevents downloading an 820px wide image for a display that's only 480px.
166166+167167+By reworking the hook to generate multiple images of different dimensions, I get the following code:
168168+169169+```go
170170+{{- $image := .Page.Resources.GetMatch .Destination -}}
171171+172172+{{- $width820 := math.Min 820 $image.Width -}}
173173+{{- $resizeOpts := printf "%dx webp q75 lanczos" (int $width820) -}}
174174+{{- $img820 := $image.Resize $resizeOpts -}}
175175+176176+{{- $width480 := math.Min 480 $image.Width -}}
177177+{{- $resizeOpts := printf "%dx webp q75 lanczos" (int $width480) -}}
178178+{{- $img480 := $image.Resize $resizeOpts -}}
179179+180180+<img srcset="{{ $img820.RelPermalink }} 820w,
181181+ {{ $img480.RelPermalink }} 480w"
182182+ sizes="(max-width: 480px) 480px,
183183+ 820px"
184184+ src="{{ $img820.RelPermalink }}"
185185+ {{- with $.PlainText }} alt="{{ . }}"{{ end -}}
186186+ {{ with $.Title }}title="{{ . }}"{{ end }}>
187187+```
188188+189189+The generated HTML looks like this:
190190+191191+```html
192192+<img srcset="/photo_hu_ed495de5ae801a42.webp 820w,
193193+ /photo_hu_21e26f92cb8d445a.webp 480w"
194194+ sizes="(max-width: 480px) 480px,
195195+ 820px"
196196+ src="/photo_hu_ed495de5ae801a42.webp"
197197+ alt="An image">
198198+```
199199+200200+Very basically, I'm resizing images to 2 sizes, 820px and 480px, and asking the browser to use the 480px version for all screen sizes below 480px and the 820px version for all other sizes.
201201+202202+We can go a bit further, but we've already done good work on images, it's time to move to the next step.
203203+204204+> These resizings are done at build time, meaning each article will exponentially increase build time (2 resizings per image).
205205+> I'll probably need to find another way soon, maybe an S3 cache, but it's a good start.
206206+207207+## Pre-compressing static resources
208208+209209+Now that the images are lighter and resized at build time by Hugo, I can tackle compressing the already minified resources (HTML, CSS, and JS).
210210+211211+Before moving on to pre-compression itself, we need to look at how the resources will be served.
212212+213213+My site is hosted on Clever Cloud, in a static instance.
214214+I wrote an article about this last year: [Deploy static apps on Clever Cloud](/2025/06//2025-06-05-static-apps-clever).
215215+216216+Clever Cloud lets you use Caddy to serve static files simply by adding a `Caddyfile` to the project root.
217217+218218+This option will let me configure Caddy to serve the site's public directory:
219219+220220+```Caddyfile
221221+# Clever Cloud needs us to listen on port 8080
222222+:8080
223223+224224+file_server {
225225+ # Clever Cloud serves the public directory
226226+ root public
227227+}
228228+229229+# Ask Caddy to compress static files
230230+encode
231231+```
232232+233233+When handling a request, Caddy will serve static files, and potentially compress HTTP responses by setting the Content-Encoding header, thanks to the encode directive. The formats used by default by Caddy are zstd and gzip, and only relevant resources are compressed (already compressed formats like jpg are not re-compressed).
234234+235235+This compression saves bandwidth and speeds up page loading.
236236+237237+However, compression uses a bit of CPU on the fly.
238238+It's then interesting to pre-compress static resources at the build phase to save some CPU.
239239+240240+A Caddy directive lets you serve pre-compressed static files: `precompressed`.
241241+Caddy will then look for compressed variants of files, in the form of sidecar files.
242242+Next to each static file, you need to generate compressed variants and name them using extensions like .gz, .br, and .zst for example.
243243+244244+Hugo doesn't let you generate these compressed variants itself, so I need to use a small script that will execute at the end of the build phase.
245245+246246+I created a `precompress` script in my `mise.toml` file:
247247+248248+```toml
249249+[tasks.build]
250250+description = "Build the site with Hugo"
251251+run = "hugo --gc --minify --destination public"
252252+253253+[tasks.precompress]
254254+description = "Precompress static resources"
255255+run = '''
256256+COMPRESSREGEX=".*(html|css|js|xml|ico|svg|md|pdf)$"
257257+find public/ -type f -regextype egrep -regex $COMPRESSREGEX | xargs zstd --keep --force -19
258258+find public/ -type f -regextype egrep -regex $COMPRESSREGEX | xargs gzip --keep --force --best
259259+'''
260260+```
261261+262262+I implemented compression with gzip using the highest compression level possible (`--best`), and with zstd with the highest compression as well (`-19`). The compression level mainly impacts compression, but little on decompression, so let's maximize the various levels.
263263+I skipped the br format because it requires installing an additional binary on my Clever Cloud instances, and gz and zst are already more than sufficient: zst will be supported by modern browsers in the most recent versions, gzip will serve as a reasonable default format.
264264+265265+To run this script, just tell Clever Cloud to execute `mise run precompress` as a post-build hook, with the `CC_POST_BUILD_HOOK` environment variable:
266266+267267+
268268+269269+The `precompress` script is inspired by a [blog post from Scott Laird](https://scottstuff.net/posts/2025/03/09/precompressing-content-with-hugo-and-caddy/) that I found while doing some research.
270270+It searches for all files matching the given regex, and uses zstd to compress these files.
271271+272272+Running these scripts produces the following output:
273273+274274+```bash
275275+[precompress] $ COMPRESSREGEX=".*(html|css|js|xml|ico|svg|md|pdf)$"
276276+245 files compressed : 80.99% ( 83.3 MiB => 67.4 MiB) B ==> 98%^T
277277+```
278278+279279+We can validate that the built files are pre-compressed as expected, with .gz and .zst extensions:
280280+281281+```bash
282282+$ ls public/
283283+2020 404.html.zst fonts index.xml.zst projects
284284+2021 ai fr js robots.txt
285285+2022 ai-manifesto icons logo_blue.png series
286286+2023 books images logo_transparent_background.png sitemap.xml
287287+2024 credentials index.html now sitemap.xml.gz
288288+2025 css index.html.gz page sitemap.xml.zst
289289+2026 ekite index.html.zst posts stats
290290+404.html en index.xml pp_ekite_itvw.png tags
291291+404.html.gz favicon.png index.xml.gz pp_ekite_itvw_hu_41404e93ad715bdf.webp talks
292292+```
293293+294294+And check the compressed file sizes:
295295+296296+```bash
297297+$ ls -al public/index.*
298298+.rw-rw-r-- jwittouck jwittouck 33 KB Wed Feb 11 12:15:21 2026 index.html
299299+.rw-rw-r-- jwittouck jwittouck 9.4 KB Wed Feb 11 12:15:21 2026 index.html.gz
300300+.rw-rw-r-- jwittouck jwittouck 9.0 KB Wed Feb 11 12:15:21 2026 index.html.zst
301301+.rw-rw-r-- jwittouck jwittouck 67 KB Wed Feb 11 12:15:22 2026 index.xml
302302+.rw-rw-r-- jwittouck jwittouck 18 KB Wed Feb 11 12:15:22 2026 index.xml.gz
303303+.rw-rw-r-- jwittouck jwittouck 17 KB Wed Feb 11 12:15:22 2026 index.xml.zst
304304+```
305305+306306+> We still have a nice gain with gzip and zstd compression, around 75%.
307307+308308+To then serve the pre-compressed files, you need to add the [`precompressed`](https://caddyserver.com/docs/caddyfile/directives/file_server#precompressed) directive in the Caddyfile:
309309+310310+```Caddyfile
311311+# Clever Cloud needs us to listen on port 8080
312312+:8080
313313+314314+file_server {
315315+ # Clever Cloud serves the public directory
316316+ root public
317317+ # serve precompressed files
318318+ precompressed
319319+}
320320+321321+# Ask Caddy to compress static files
322322+encode
323323+```
324324+325325+The `precompressed` directive will look for .zst and .gz files in order to serve them first, and use on-the-fly compression as a fallback.
326326+327327+We can then simply verify that the compressed files are served compressed with a curl command.
328328+329329+Here's what was returned before compression:
330330+331331+```bash
332332+$ curl --head https://codeka.io
333333+334334+Content-Length: 34963
335335+Content-Type: text/html; charset=utf-8
336336+Server: Caddy
337337+```
338338+339339+And the same command after compression:
340340+341341+```bash
342342+$ curl --compressed --head https://codeka.io
343343+344344+HTTP/1.1 200 OK
345345+Content-Encoding: zstd
346346+Content-Type: text/html; charset=utf-8
347347+Server: Caddy
348348+Content-Length: 9
349349+```
350350+351351+We go from a 34KB HTML page to 9KB of compressed data, without impacting server CPU since compression happens at build!
352352+353353+## Security headers
354354+355355+The final step in this configuration is to modernize the headers served to implement some additional security.
356356+357357+Now that Caddy serves the site and I have a Caddyfile that I can control, I can easily control the HTTP headers returned.
358358+359359+To know what to do, on Antoine's advice, I used the [MDN Observatory](https://developer.mozilla.org/en-US/observatory) analyzer:
360360+361361+Here's my score, once again not very flattering:
362362+363363+
364364+365365+> Once again, the analysis result is poor, since no optimization had been done. There's work to be done on this part!
366366+367367+### HSTS and easy headers
368368+369369+The first interesting header to use is Strict-Transport-Security.
370370+371371+This header has the effect of forcing browsers to use HTTPS.
372372+Although I've already configured an HTTP to HTTPS redirect on my domain with Clever Cloud, it's an additional security measure.
373373+374374+MDN's recommendation is to set this value:
375375+376376+```
377377+Strict-Transport-Security: max-age=63072000
378378+```
379379+380380+In my Caddyfile, nothing simpler, I add the Strict-Transport-Security header:
381381+382382+```Caddyfile
383383+# Clever Cloud needs us to listen on port 8080
384384+:8080
385385+386386+file_server {
387387+ # Clever Cloud serves the public directory
388388+ root public
389389+ precompressed
390390+}
391391+392392+# Custom headers for security
393393+header {
394394+ Strict-Transport-Security "max-age=63072000"
395395+ X-Content-Type-Options nosniff
396396+}
397397+398398+# Ask Caddy to compress static files
399399+encode
400400+```
401401+402402+I do the same for the X-Content-Type-Options header, which essentially prevents a style tag from loading anything other than CSS.
403403+404404+### Content-Security-Policy
405405+406406+This header, a bit more complex to implement, tells the browser what security policy to apply when executing scripts from external sources to the website.
407407+It's a security measure to protect against XSS (Cross-Site Scripting) injections.
408408+409409+The header must declare all accepted sources (domains) for loading scripts, styles, images, and other resources.
410410+Using this header also has the effect of disabling inline CSS and JS, which is rather a good practice.
411411+412412+After removing all inline styles from my site, I configured the header in my Caddyfile:
413413+414414+```Caddyfile
415415+# Clever Cloud needs us to listen on port 8080
416416+:8080
417417+418418+file_server {
419419+ # Clever Cloud serves the public directory
420420+ root public
421421+ precompressed
422422+}
423423+424424+# Custom headers for security
425425+header {
426426+ Strict-Transport-Security "max-age=63072000"
427427+ X-Content-Type-Options nosniff
428428+429429+ Content-Security-Policy "
430430+ script-src 'self' codeka.io plausible.io;
431431+ connect-src 'self' codeka.io plausible.io;
432432+433433+ frame-src 'self' plausible.io www.youtube-nocookie.com openfeedback.io;
434434+ frame-ancestors 'none';
435435+436436+ img-src 'self' img.shields.io;
437437+438438+ default-src 'self';
439439+ "
440440+}
441441+442442+# Ask Caddy to compress static files
443443+encode
444444+```
445445+446446+For the script-src and connect-src directives, since I use plausible.io to track visits to my articles, its script must be able to load and open outgoing connections. Similarly, I have iframes (booo) on my talk pages that reference YouTube videos and OpenFeedback.io feedback. So I must also authorize these resources with the frame-src directive. The frame-ancestor directive blocks using my site in an external iframe (it wasn't entirely necessary, but it doesn't hurt to add it).
447447+The img-src directive lets me authorize images coming from shields.io, which I use to display some badges.
448448+Finally, the default-src directive serves as a fallback for all possible directives, and indicates that only my site is an authorized source.
449449+450450+## So what does it give?
451451+452452+After all these modifications, here are the Lighthouse analysis results:
453453+454454+Here are my site's current scores, for mobile and desktop browsing:
455455+456456+
457457+
458458+{ class="images-grid-2" }
459459+460460+96 and 100 in performance on the homepage, better than the initial 91, mission accomplished here.
461461+462462+For the page that had a really bad result, the result is a bit more mixed:
463463+464464+
465465+
466466+{ class="images-grid-2" }
467467+468468+The initial scores were 43 on mobile and 58 on desktop.
469469+Digging a bit, it's the iframes that are killing the performance, so I can't do much about it.
470470+471471+On the security headers side, I reached perfection with a nice score of 105/100, an A+, instead of the initial D-:
472472+473473+
474474+475475+## Conclusion
476476+477477+It took me a good half-day to implement all these mechanisms, but I came out with a better understanding of security and HTTP compression.
478478+I also discovered Caddy, and improved my mise.toml file.
479479+480480+And the result is no small thing. The optimization is real (even though I didn't take the time to measure everything precisely).
481481+482482+For most of my readers, the compression impact will probably be minimal, because on high-performance networks, the difference in loading time may not be felt much.
483483+But with compression done only at build time, it's also less CPU consumed, which should allow me to stay on the smallest instances for my site as long as possible.
484484+485485+I'll still need to address the build time issue, which could become a problem down the road. I might test an architecture with a small Varnish cache in front of a bucket.
486486+487487+## Links and references
488488+489489+* Hugo documentation:
490490+ * Configuring [image optimization with Hugo](https://gohugo.io/configuration/imaging/#quality)
491491+ * Hugo's [Resize method](https://gohugo.io/methods/resource/resize/)
492492+ * [Formats supported by Hugo](https://gohugo.io/functions/images/process/#format)
493493+ * [Hugo's image render hook](https://gohugo.io/render-hooks/images/#article)
494494+495495+* Caddy documentation:
496496+ * The [`encode` directive](https://caddyserver.com/docs/caddyfile/directives/encode#syntax)
497497+ * The [`precompressed` directive](https://caddyserver.com/docs/caddyfile/directives/file_server#precompressed)
498498+499499+* MDN documentation:
500500+ * [Responsive Images](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images)
501501+ * [MDN HTTP Observatory](https://developer.mozilla.org/en-US/observatory)
502502+ * [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)
503503+ * [HTTP Strict Transport Security implementation](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security_implementation)
504504+ * [MIME type verification](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types)
505505+506506+* [Precompressing Content With Hugo and Caddy](https://scottstuff.net/posts/2025/03/09/precompressing-content-with-hugo-and-caddy/)
507507+508508+* The excellent talk by Antoine Caron and Hubert Sablonière: [La compression Web : comment (re)prendre le contrôle ?](https://www.youtube.com/watch?v=LWd0hr6ljZk)
509509+510510+* The article by Denis Germain, who did the same thing as me this week: [Optimisation webperf : AVIF et pré-compression pour le blog](https://blog.zwindler.fr/2026/02/19/optimisation-webperf-avif-precompression/)