Source code of my website
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

🌐 : translate article to english

+510
+510
content/posts/2026/2026-02-20-optimiser-site-statique/index.en.md
··· 1 + --- 2 + date: 2026-02-20 3 + title: Optimizing a Hugo site's performance and security 4 + tags: 5 + - clevercloud 6 + - security 7 + - devops 8 + - tools 9 + --- 10 + 11 + Following the good advice from my friend [Antoine Caron](https://blog.slashgear.dev/), I took some time this week to optimize my site. 12 + 13 + The site you're reading is a static site built with Hugo. 14 + 15 + I had already done some work on compressing the various resources, mainly images, but I stopped there. 16 + 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. 17 + 18 + <!-- more --> 19 + 20 + ## The Lighthouse score 21 + 22 + 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). 23 + 24 + Lighthouse lets you get a view of an application or website's performance in just a few minutes, for both desktop and mobile targets. 25 + It also lets you validate certain accessibility properties, such as contrast, presence of alternative text for screen readers, etc. 26 + 27 + I think it's a good starting point. 28 + 29 + Here are my site's current scores, for mobile and desktop browsing: 30 + 31 + ![Lighthouse score for mobile](lighthouse-mobile.webp) 32 + ![Lighthouse score for desktop](lighthouse-desktop.webp) 33 + { class="images-grid-2" } 34 + 35 + These scores may seem interesting on the homepage, but they degrade significantly on certain pages. 36 + Here are the scores for my Factorio talk page: 37 + 38 + ![Lighthouse score on mobile for another page](lighthouse-talk-mobile.webp) 39 + ![Lighthouse score on desktop for another page](lighthouse-talk-desktop.webp) 40 + { class="images-grid-2" } 41 + 42 + > I clearly have room for improvement on accessibility and performance. Things aren't going well there at all. 43 + 44 + Without going into detail and analyzing what this tool reports, let's dive right into the main topic. 45 + 46 + ## Minifying HTML, CSS, and JS 47 + 48 + A first step involves minifying static resources: HTML, CSS, and JS. 49 + 50 + This step is very simple to implement, as Hugo already supports it. 51 + You just need to add the `--minify` flag during the build to ask Hugo to minify all resources. 52 + 53 + My build command in my `mise.toml` is as follows: 54 + 55 + ```toml 56 + [tasks.build] 57 + description = "Build the site with Hugo" 58 + run = "hugo --gc --minify --destination public" 59 + ``` 60 + 61 + This produces minified HTML files like this one: 62 + 63 + ![My minified index.html file](index-html-minified.webp "My minified index.html file") 64 + 65 + No surprises or difficulties here, we can quickly move on to the next step 🚶 66 + 67 + > I included this section for completeness; my static resources were already minified. But I wanted a complete approach and to verify this point too. 68 + 69 + ## Converting images to webp 70 + 71 + 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. 72 + 73 + 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. 74 + 75 + 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. 76 + 77 + > It's quite possible I'll change my mind on this quickly and switch to avif as soon as Hugo supports it. 78 + 79 + I started by converting my images to webp. 80 + I did this in one shot with a script using the `cwebp` CLI for Linux: 81 + 82 + ```shell 83 + # parallelize conversions to use all available CPUs 84 + JOBS="$(nproc)" 85 + # find images 86 + find . -type f -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \ 87 + # convert to webp 88 + | xargs -n 1 -P "$JOBS" -I IMG sh -c 'cwebp -q 75 IMG -o $(echo "IMG" | sed "s/\.[^.]*$/.webp/")' 89 + ``` 90 + 91 + Then a big `sed` to replace the references in my markdown: 92 + 93 + ```shell 94 + sed -Ei 's/\.(jpe?g|png)$/\.webp/I' **/*.md 95 + 96 + find . -type f -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' | rm 97 + ``` 98 + 99 + The images are now in webp format, which will save some space and download time for readers. 100 + 101 + I haven't calculated the size reduction, but for source images, we must be close to 60% of their original size: 102 + 103 + ```shell 104 + ❯ ls -alh 105 + .rw-r--r-- jwittouck jwittouck 65 KB Tue Dec 30 12:17:33 2025 clever-addon-create.png 106 + .rw-rw-r-- jwittouck jwittouck 20 KB Fri Feb 20 10:28:43 2026 clever-addon-create.webp 107 + .rw-r--r-- jwittouck jwittouck 69 KB Tue Dec 30 12:17:33 2025 clever-env.png 108 + .rw-r--r-- jwittouck jwittouck 26 KB Fri Feb 20 10:28:43 2026 clever-env.webp 109 + .rw-r--r-- jwittouck jwittouck 48 KB Tue Dec 30 12:17:33 2025 clever-open-starting.png 110 + .rw-r--r-- jwittouck jwittouck 15 KB Fri Feb 20 10:28:43 2026 clever-open-starting.webp 111 + ``` 112 + 113 + ## Resizing to desired sizes 114 + 115 + 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. 116 + 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. 117 + 118 + The default hook is as follows: 119 + 120 + ```go 121 + <img src="{{ .Destination | safeURL }}" 122 + {{- with .PlainText }} alt="{{ . }}"{{ end -}} 123 + {{- with .Title }} title="{{ . }}"{{ end -}} 124 + > 125 + ``` 126 + 127 + An image declared in Markdown like this: 128 + 129 + ```markdown 130 + ![An image](photo.jpg) 131 + ``` 132 + 133 + Will have the following HTML equivalent: 134 + 135 + ```html 136 + <img src="/photo.jpg" alt="An image"> 137 + ``` 138 + 139 + To resize images to a maximum width of 820px (the width used on this site's content column), I use the following hook: 140 + 141 + ```go 142 + {{- $image := .Page.Resources.GetMatch .Destination -}} 143 + 144 + {{- $width := math.Min 820 $image.Width -}} 145 + {{- $resizeOpts := printf "%dx webp q75 lanczos" (int $width) -}} 146 + 147 + {{- with $image.Resize $resizeOpts -}} 148 + <img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" 149 + {{- with $.PlainText }} alt="{{ . }}"{{ end -}} 150 + {{ with $.Title }}title="{{ . }}"{{ end }}> 151 + {{- end -}} 152 + ``` 153 + 154 + The magic happens in the first few lines. 155 + I resize the image to a maximum width of 820px (or less if the image is smaller). 156 + 157 + The HTML generated by Hugo for my images is now as follows: 158 + 159 + ```html 160 + <img src="/photo_hu_ed495de5ae801a42.webp" width="820" height="540" alt="An image"> 161 + ``` 162 + 163 + 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. 164 + 165 + 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. 166 + 167 + By reworking the hook to generate multiple images of different dimensions, I get the following code: 168 + 169 + ```go 170 + {{- $image := .Page.Resources.GetMatch .Destination -}} 171 + 172 + {{- $width820 := math.Min 820 $image.Width -}} 173 + {{- $resizeOpts := printf "%dx webp q75 lanczos" (int $width820) -}} 174 + {{- $img820 := $image.Resize $resizeOpts -}} 175 + 176 + {{- $width480 := math.Min 480 $image.Width -}} 177 + {{- $resizeOpts := printf "%dx webp q75 lanczos" (int $width480) -}} 178 + {{- $img480 := $image.Resize $resizeOpts -}} 179 + 180 + <img srcset="{{ $img820.RelPermalink }} 820w, 181 + {{ $img480.RelPermalink }} 480w" 182 + sizes="(max-width: 480px) 480px, 183 + 820px" 184 + src="{{ $img820.RelPermalink }}" 185 + {{- with $.PlainText }} alt="{{ . }}"{{ end -}} 186 + {{ with $.Title }}title="{{ . }}"{{ end }}> 187 + ``` 188 + 189 + The generated HTML looks like this: 190 + 191 + ```html 192 + <img srcset="/photo_hu_ed495de5ae801a42.webp 820w, 193 + /photo_hu_21e26f92cb8d445a.webp 480w" 194 + sizes="(max-width: 480px) 480px, 195 + 820px" 196 + src="/photo_hu_ed495de5ae801a42.webp" 197 + alt="An image"> 198 + ``` 199 + 200 + 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. 201 + 202 + We can go a bit further, but we've already done good work on images, it's time to move to the next step. 203 + 204 + > These resizings are done at build time, meaning each article will exponentially increase build time (2 resizings per image). 205 + > I'll probably need to find another way soon, maybe an S3 cache, but it's a good start. 206 + 207 + ## Pre-compressing static resources 208 + 209 + 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). 210 + 211 + Before moving on to pre-compression itself, we need to look at how the resources will be served. 212 + 213 + My site is hosted on Clever Cloud, in a static instance. 214 + I wrote an article about this last year: [Deploy static apps on Clever Cloud](/2025/06//2025-06-05-static-apps-clever). 215 + 216 + Clever Cloud lets you use Caddy to serve static files simply by adding a `Caddyfile` to the project root. 217 + 218 + This option will let me configure Caddy to serve the site's public directory: 219 + 220 + ```Caddyfile 221 + # Clever Cloud needs us to listen on port 8080 222 + :8080 223 + 224 + file_server { 225 + # Clever Cloud serves the public directory 226 + root public 227 + } 228 + 229 + # Ask Caddy to compress static files 230 + encode 231 + ``` 232 + 233 + 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). 234 + 235 + This compression saves bandwidth and speeds up page loading. 236 + 237 + However, compression uses a bit of CPU on the fly. 238 + It's then interesting to pre-compress static resources at the build phase to save some CPU. 239 + 240 + A Caddy directive lets you serve pre-compressed static files: `precompressed`. 241 + Caddy will then look for compressed variants of files, in the form of sidecar files. 242 + Next to each static file, you need to generate compressed variants and name them using extensions like .gz, .br, and .zst for example. 243 + 244 + 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. 245 + 246 + I created a `precompress` script in my `mise.toml` file: 247 + 248 + ```toml 249 + [tasks.build] 250 + description = "Build the site with Hugo" 251 + run = "hugo --gc --minify --destination public" 252 + 253 + [tasks.precompress] 254 + description = "Precompress static resources" 255 + run = ''' 256 + COMPRESSREGEX=".*(html|css|js|xml|ico|svg|md|pdf)$" 257 + find public/ -type f -regextype egrep -regex $COMPRESSREGEX | xargs zstd --keep --force -19 258 + find public/ -type f -regextype egrep -regex $COMPRESSREGEX | xargs gzip --keep --force --best 259 + ''' 260 + ``` 261 + 262 + 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. 263 + 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. 264 + 265 + 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: 266 + 267 + ![Clever Cloud post build hook](clever-post-build-hook.webp) 268 + 269 + 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. 270 + It searches for all files matching the given regex, and uses zstd to compress these files. 271 + 272 + Running these scripts produces the following output: 273 + 274 + ```bash 275 + [precompress] $ COMPRESSREGEX=".*(html|css|js|xml|ico|svg|md|pdf)$" 276 + 245 files compressed : 80.99% ( 83.3 MiB => 67.4 MiB) B ==> 98%^T 277 + ``` 278 + 279 + We can validate that the built files are pre-compressed as expected, with .gz and .zst extensions: 280 + 281 + ```bash 282 + $ ls public/ 283 + 2020 404.html.zst fonts index.xml.zst projects 284 + 2021 ai fr js robots.txt 285 + 2022 ai-manifesto icons logo_blue.png series 286 + 2023 books images logo_transparent_background.png sitemap.xml 287 + 2024 credentials index.html now sitemap.xml.gz 288 + 2025 css index.html.gz page sitemap.xml.zst 289 + 2026 ekite index.html.zst posts stats 290 + 404.html en index.xml pp_ekite_itvw.png tags 291 + 404.html.gz favicon.png index.xml.gz pp_ekite_itvw_hu_41404e93ad715bdf.webp talks 292 + ``` 293 + 294 + And check the compressed file sizes: 295 + 296 + ```bash 297 + $ ls -al public/index.* 298 + .rw-rw-r-- jwittouck jwittouck 33 KB Wed Feb 11 12:15:21 2026 index.html 299 + .rw-rw-r-- jwittouck jwittouck 9.4 KB Wed Feb 11 12:15:21 2026 index.html.gz 300 + .rw-rw-r-- jwittouck jwittouck 9.0 KB Wed Feb 11 12:15:21 2026 index.html.zst 301 + .rw-rw-r-- jwittouck jwittouck 67 KB Wed Feb 11 12:15:22 2026 index.xml 302 + .rw-rw-r-- jwittouck jwittouck 18 KB Wed Feb 11 12:15:22 2026 index.xml.gz 303 + .rw-rw-r-- jwittouck jwittouck 17 KB Wed Feb 11 12:15:22 2026 index.xml.zst 304 + ``` 305 + 306 + > We still have a nice gain with gzip and zstd compression, around 75%. 307 + 308 + 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: 309 + 310 + ```Caddyfile 311 + # Clever Cloud needs us to listen on port 8080 312 + :8080 313 + 314 + file_server { 315 + # Clever Cloud serves the public directory 316 + root public 317 + # serve precompressed files 318 + precompressed 319 + } 320 + 321 + # Ask Caddy to compress static files 322 + encode 323 + ``` 324 + 325 + 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. 326 + 327 + We can then simply verify that the compressed files are served compressed with a curl command. 328 + 329 + Here's what was returned before compression: 330 + 331 + ```bash 332 + $ curl --head https://codeka.io 333 + 334 + Content-Length: 34963 335 + Content-Type: text/html; charset=utf-8 336 + Server: Caddy 337 + ``` 338 + 339 + And the same command after compression: 340 + 341 + ```bash 342 + $ curl --compressed --head https://codeka.io 343 + 344 + HTTP/1.1 200 OK 345 + Content-Encoding: zstd 346 + Content-Type: text/html; charset=utf-8 347 + Server: Caddy 348 + Content-Length: 9 349 + ``` 350 + 351 + We go from a 34KB HTML page to 9KB of compressed data, without impacting server CPU since compression happens at build! 352 + 353 + ## Security headers 354 + 355 + The final step in this configuration is to modernize the headers served to implement some additional security. 356 + 357 + Now that Caddy serves the site and I have a Caddyfile that I can control, I can easily control the HTTP headers returned. 358 + 359 + To know what to do, on Antoine's advice, I used the [MDN Observatory](https://developer.mozilla.org/en-US/observatory) analyzer: 360 + 361 + Here's my score, once again not very flattering: 362 + 363 + ![MDN analysis result](mdn-analysis.webp "MDN analysis result") 364 + 365 + > Once again, the analysis result is poor, since no optimization had been done. There's work to be done on this part! 366 + 367 + ### HSTS and easy headers 368 + 369 + The first interesting header to use is Strict-Transport-Security. 370 + 371 + This header has the effect of forcing browsers to use HTTPS. 372 + Although I've already configured an HTTP to HTTPS redirect on my domain with Clever Cloud, it's an additional security measure. 373 + 374 + MDN's recommendation is to set this value: 375 + 376 + ``` 377 + Strict-Transport-Security: max-age=63072000 378 + ``` 379 + 380 + In my Caddyfile, nothing simpler, I add the Strict-Transport-Security header: 381 + 382 + ```Caddyfile 383 + # Clever Cloud needs us to listen on port 8080 384 + :8080 385 + 386 + file_server { 387 + # Clever Cloud serves the public directory 388 + root public 389 + precompressed 390 + } 391 + 392 + # Custom headers for security 393 + header { 394 + Strict-Transport-Security "max-age=63072000" 395 + X-Content-Type-Options nosniff 396 + } 397 + 398 + # Ask Caddy to compress static files 399 + encode 400 + ``` 401 + 402 + I do the same for the X-Content-Type-Options header, which essentially prevents a style tag from loading anything other than CSS. 403 + 404 + ### Content-Security-Policy 405 + 406 + 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. 407 + It's a security measure to protect against XSS (Cross-Site Scripting) injections. 408 + 409 + The header must declare all accepted sources (domains) for loading scripts, styles, images, and other resources. 410 + Using this header also has the effect of disabling inline CSS and JS, which is rather a good practice. 411 + 412 + After removing all inline styles from my site, I configured the header in my Caddyfile: 413 + 414 + ```Caddyfile 415 + # Clever Cloud needs us to listen on port 8080 416 + :8080 417 + 418 + file_server { 419 + # Clever Cloud serves the public directory 420 + root public 421 + precompressed 422 + } 423 + 424 + # Custom headers for security 425 + header { 426 + Strict-Transport-Security "max-age=63072000" 427 + X-Content-Type-Options nosniff 428 + 429 + Content-Security-Policy " 430 + script-src 'self' codeka.io plausible.io; 431 + connect-src 'self' codeka.io plausible.io; 432 + 433 + frame-src 'self' plausible.io www.youtube-nocookie.com openfeedback.io; 434 + frame-ancestors 'none'; 435 + 436 + img-src 'self' img.shields.io; 437 + 438 + default-src 'self'; 439 + " 440 + } 441 + 442 + # Ask Caddy to compress static files 443 + encode 444 + ``` 445 + 446 + 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). 447 + The img-src directive lets me authorize images coming from shields.io, which I use to display some badges. 448 + Finally, the default-src directive serves as a fallback for all possible directives, and indicates that only my site is an authorized source. 449 + 450 + ## So what does it give? 451 + 452 + After all these modifications, here are the Lighthouse analysis results: 453 + 454 + Here are my site's current scores, for mobile and desktop browsing: 455 + 456 + ![Lighthouse score for mobile](lighthouse-mobile-after.webp) 457 + ![Lighthouse score for desktop](lighthouse-desktop-after.webp) 458 + { class="images-grid-2" } 459 + 460 + 96 and 100 in performance on the homepage, better than the initial 91, mission accomplished here. 461 + 462 + For the page that had a really bad result, the result is a bit more mixed: 463 + 464 + ![Lighthouse score on mobile for another page](lighthouse-talk-mobile-after.webp) 465 + ![Lighthouse score on desktop for another page](lighthouse-talk-desktop-after.webp) 466 + { class="images-grid-2" } 467 + 468 + The initial scores were 43 on mobile and 58 on desktop. 469 + Digging a bit, it's the iframes that are killing the performance, so I can't do much about it. 470 + 471 + On the security headers side, I reached perfection with a nice score of 105/100, an A+, instead of the initial D-: 472 + 473 + ![MDN A+ score detail](mdn-after.webp) 474 + 475 + ## Conclusion 476 + 477 + 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. 478 + I also discovered Caddy, and improved my mise.toml file. 479 + 480 + And the result is no small thing. The optimization is real (even though I didn't take the time to measure everything precisely). 481 + 482 + 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. 483 + 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. 484 + 485 + 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. 486 + 487 + ## Links and references 488 + 489 + * Hugo documentation: 490 + * Configuring [image optimization with Hugo](https://gohugo.io/configuration/imaging/#quality) 491 + * Hugo's [Resize method](https://gohugo.io/methods/resource/resize/) 492 + * [Formats supported by Hugo](https://gohugo.io/functions/images/process/#format) 493 + * [Hugo's image render hook](https://gohugo.io/render-hooks/images/#article) 494 + 495 + * Caddy documentation: 496 + * The [`encode` directive](https://caddyserver.com/docs/caddyfile/directives/encode#syntax) 497 + * The [`precompressed` directive](https://caddyserver.com/docs/caddyfile/directives/file_server#precompressed) 498 + 499 + * MDN documentation: 500 + * [Responsive Images](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images) 501 + * [MDN HTTP Observatory](https://developer.mozilla.org/en-US/observatory) 502 + * [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy) 503 + * [HTTP Strict Transport Security implementation](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/TLS#http_strict_transport_security_implementation) 504 + * [MIME type verification](https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/MIME_types) 505 + 506 + * [Precompressing Content With Hugo and Caddy](https://scottstuff.net/posts/2025/03/09/precompressing-content-with-hugo-and-caddy/) 507 + 508 + * 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) 509 + 510 + * 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/)